dx_forge/version/
snapshot.rs

1//! Version control snapshots - commit-like snapshots for tool state
2//!
3//! Provides Git-like version control features for DX tool states, including:
4//! - Creating snapshots of tool state
5//! - Branching and merging
6//! - Version history
7//! - Diff computation
8
9use anyhow::Result;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use crate::storage::Database;
17use super::types::Version;
18
19/// Unique identifier for a snapshot
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct SnapshotId(String);
22
23impl SnapshotId {
24    /// Create a new snapshot ID from content hash
25    pub fn from_hash(hash: &[u8]) -> Self {
26        Self(format!("{:x}", Sha256::digest(hash)))
27    }
28
29    /// Create from string
30    pub fn from_str(s: impl Into<String>) -> Self {
31        Self(s.into())
32    }
33
34    /// Get the hash string
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl std::fmt::Display for SnapshotId {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}", &self.0[..8]) // Show first 8 chars like Git
43    }
44}
45
46/// A snapshot of tool state at a point in time
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Snapshot {
49    /// Unique identifier
50    pub id: SnapshotId,
51
52    /// Parent snapshot(s) - multiple parents for merges
53    pub parents: Vec<SnapshotId>,
54
55    /// Snapshot message
56    pub message: String,
57
58    /// Author
59    pub author: String,
60
61    /// Timestamp
62    pub timestamp: DateTime<Utc>,
63
64    /// Tool state at this snapshot
65    pub tool_states: HashMap<String, ToolState>,
66
67    /// Files tracked in this snapshot
68    pub files: HashMap<PathBuf, FileSnapshot>,
69
70    /// Snapshot metadata
71    pub metadata: HashMap<String, String>,
72}
73
74/// State of a tool at snapshot time
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ToolState {
77    pub tool_name: String,
78    pub version: Version,
79    pub config: HashMap<String, serde_json::Value>,
80    pub output_files: Vec<PathBuf>,
81}
82
83/// Snapshot of a file's state
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct FileSnapshot {
86    pub path: PathBuf,
87    pub hash: String,
88    pub size: u64,
89    pub modified: DateTime<Utc>,
90}
91
92impl FileSnapshot {
93    /// Create a file snapshot from a path
94    pub fn from_path(path: &Path) -> Result<Self> {
95        let content = std::fs::read(path)?;
96        let hash = format!("{:x}", Sha256::digest(&content));
97        let metadata = std::fs::metadata(path)?;
98        let modified = metadata.modified()
99            .map(|t| DateTime::<Utc>::from(t))
100            .unwrap_or_else(|_| Utc::now());
101
102        Ok(Self {
103            path: path.to_path_buf(),
104            hash,
105            size: metadata.len(),
106            modified,
107        })
108    }
109
110    /// Check if file has changed since snapshot
111    pub fn has_changed(&self) -> Result<bool> {
112        if !self.path.exists() {
113            return Ok(true);
114        }
115
116        let content = std::fs::read(&self.path)?;
117        let current_hash = format!("{:x}", Sha256::digest(&content));
118        Ok(current_hash != self.hash)
119    }
120}
121
122/// Branch information
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Branch {
125    pub name: String,
126    pub head: SnapshotId,
127    pub created_at: DateTime<Utc>,
128    pub updated_at: DateTime<Utc>,
129}
130
131/// Snapshot manager for version control
132pub struct SnapshotManager {
133    _db: Database,
134    snapshots_path: PathBuf,
135    branches_path: PathBuf,
136    current_branch: String,
137}
138
139impl SnapshotManager {
140    /// Create a new snapshot manager
141    pub fn new(forge_dir: &Path) -> Result<Self> {
142        let db_path = forge_dir.join("forge.db");  // Changed from snapshots.db
143        let db = Database::new(&db_path)?;
144
145        let snapshots_path = forge_dir.join("snapshots");
146        let branches_path = forge_dir.join("branches.json");
147
148        std::fs::create_dir_all(&snapshots_path)?;
149
150        // Initialize with main branch if needed
151        let current_branch = if branches_path.exists() {
152            let content = std::fs::read_to_string(&branches_path)?;
153            let branches: HashMap<String, Branch> = serde_json::from_str(&content)?;
154            branches.keys().next().cloned().unwrap_or_else(|| "main".to_string())
155        } else {
156            "main".to_string()
157        };
158
159        Ok(Self {
160            _db: db,
161            snapshots_path,
162            branches_path,
163            current_branch,
164        })
165    }
166
167    /// Create a new snapshot
168    pub fn create_snapshot(
169        &mut self,
170        message: impl Into<String>,
171        tool_states: HashMap<String, ToolState>,
172        files: Vec<PathBuf>,
173    ) -> Result<SnapshotId> {
174        let author = whoami::username();
175        let timestamp = Utc::now();
176
177        // Create file snapshots
178        let mut file_snapshots = HashMap::new();
179        for file in files {
180            if file.exists() {
181                let snapshot = FileSnapshot::from_path(&file)?;
182                file_snapshots.insert(file, snapshot);
183            }
184        }
185
186        // Get parent snapshot from current branch
187        let parents = self.get_branch_head(&self.current_branch)?
188            .map(|head| vec![head])
189            .unwrap_or_default();
190
191        // Compute snapshot ID
192        let content = serde_json::to_vec(&(&tool_states, &file_snapshots))?;
193        let id = SnapshotId::from_hash(&content);
194
195        let snapshot = Snapshot {
196            id: id.clone(),
197            parents,
198            message: message.into(),
199            author,
200            timestamp,
201            tool_states,
202            files: file_snapshots,
203            metadata: HashMap::new(),
204        };
205
206        // Save snapshot
207        self.save_snapshot(&snapshot)?;
208
209        // Update branch head (clone current_branch to avoid borrow conflict)
210        let current_branch = self.current_branch.clone();
211        self.update_branch_head(&current_branch, id.clone())?;
212
213        tracing::info!("Created snapshot {} on branch {}", id, self.current_branch);
214        Ok(id)
215    }
216
217    /// Get a snapshot by ID
218    pub fn get_snapshot(&self, id: &SnapshotId) -> Result<Option<Snapshot>> {
219        let snapshot_file = self.snapshots_path.join(format!("{}.json", id.as_str()));
220        
221        if !snapshot_file.exists() {
222            return Ok(None);
223        }
224
225        let content = std::fs::read_to_string(&snapshot_file)?;
226        let snapshot: Snapshot = serde_json::from_str(&content)?;
227        Ok(Some(snapshot))
228    }
229
230    /// Create a new branch
231    pub fn create_branch(&mut self, name: impl Into<String>) -> Result<()> {
232        let name = name.into();
233        
234        let head = self.get_branch_head(&self.current_branch)?
235            .ok_or_else(|| anyhow::anyhow!("Current branch has no commits"))?;
236
237        let branch = Branch {
238            name: name.clone(),
239            head,
240            created_at: Utc::now(),
241            updated_at: Utc::now(),
242        };
243
244        self.save_branch(&branch)?;
245        tracing::info!("Created branch {}", name);
246        Ok(())
247    }
248
249    /// Switch to a different branch
250    pub fn checkout_branch(&mut self, name: impl Into<String>) -> Result<()> {
251        let name = name.into();
252        
253        if !self.branch_exists(&name) {
254            anyhow::bail!("Branch {} does not exist", name);
255        }
256
257        self.current_branch = name.clone();
258        tracing::info!("Switched to branch {}", name);
259        Ok(())
260    }
261
262    /// Get current branch name
263    pub fn current_branch(&self) -> &str {
264        &self.current_branch
265    }
266
267    /// List all branches
268    pub fn list_branches(&self) -> Result<Vec<Branch>> {
269        if !self.branches_path.exists() {
270            return Ok(vec![]);
271        }
272
273        let content = std::fs::read_to_string(&self.branches_path)?;
274        let branches: HashMap<String, Branch> = serde_json::from_str(&content)?;
275        Ok(branches.into_values().collect())
276    }
277
278    /// Get commit history for current branch
279    pub fn history(&self, limit: usize) -> Result<Vec<Snapshot>> {
280        let head = self.get_branch_head(&self.current_branch)?;
281        
282        if head.is_none() {
283            return Ok(vec![]);
284        }
285
286        let mut history = Vec::new();
287        let mut current = head;
288
289        while let Some(id) = current {
290            if history.len() >= limit {
291                break;
292            }
293
294            if let Some(snapshot) = self.get_snapshot(&id)? {
295                current = snapshot.parents.first().cloned();
296                history.push(snapshot);
297            } else {
298                break;
299            }
300        }
301
302        Ok(history)
303    }
304
305    /// Merge a branch into current branch
306    pub fn merge(&mut self, source_branch: impl Into<String>, message: impl Into<String>) -> Result<SnapshotId> {
307        let source_branch = source_branch.into();
308        
309        let source_head = self.get_branch_head(&source_branch)?
310            .ok_or_else(|| anyhow::anyhow!("Source branch has no commits"))?;
311
312        let target_head = self.get_branch_head(&self.current_branch)?
313            .ok_or_else(|| anyhow::anyhow!("Current branch has no commits"))?;
314
315        // Get snapshots
316        let source_snap = self.get_snapshot(&source_head)?
317            .ok_or_else(|| anyhow::anyhow!("Source snapshot not found"))?;
318
319        let target_snap = self.get_snapshot(&target_head)?
320            .ok_or_else(|| anyhow::anyhow!("Target snapshot not found"))?;
321
322        // Merge tool states (target takes precedence for conflicts)
323        let mut merged_states = target_snap.tool_states.clone();
324        for (name, state) in source_snap.tool_states {
325            merged_states.entry(name).or_insert(state);
326        }
327
328        // Merge files
329        let mut merged_files = target_snap.files.clone();
330        for (path, file) in source_snap.files {
331            merged_files.entry(path).or_insert(file);
332        }
333
334        // Create merge snapshot with both parents
335        let author = whoami::username();
336        let timestamp = Utc::now();
337
338        let content = serde_json::to_vec(&(&merged_states, &merged_files))?;
339        let id = SnapshotId::from_hash(&content);
340
341        let snapshot = Snapshot {
342            id: id.clone(),
343            parents: vec![target_head, source_head],
344            message: message.into(),
345            author,
346            timestamp,
347            tool_states: merged_states,
348            files: merged_files,
349            metadata: HashMap::new(),
350        };
351
352        self.save_snapshot(&snapshot)?;
353        
354        // Update branch head (clone to avoid borrow conflict)
355        let current_branch = self.current_branch.clone();
356        self.update_branch_head(&current_branch, id.clone())?;
357
358        tracing::info!("Merged {} into {} ({})", source_branch, self.current_branch, id);
359        Ok(id)
360    }
361
362    /// Compute diff between two snapshots
363    pub fn diff(&self, from: &SnapshotId, to: &SnapshotId) -> Result<SnapshotDiff> {
364        let from_snap = self.get_snapshot(from)?
365            .ok_or_else(|| anyhow::anyhow!("From snapshot not found"))?;
366
367        let to_snap = self.get_snapshot(to)?
368            .ok_or_else(|| anyhow::anyhow!("To snapshot not found"))?;
369
370        let mut added_files = Vec::new();
371        let mut modified_files = Vec::new();
372        let mut deleted_files = Vec::new();
373
374        // Find added and modified files
375        for (path, to_file) in &to_snap.files {
376            match from_snap.files.get(path) {
377                Some(from_file) => {
378                    if from_file.hash != to_file.hash {
379                        modified_files.push(path.clone());
380                    }
381                }
382                None => {
383                    added_files.push(path.clone());
384                }
385            }
386        }
387
388        // Find deleted files
389        for path in from_snap.files.keys() {
390            if !to_snap.files.contains_key(path) {
391                deleted_files.push(path.clone());
392            }
393        }
394
395        Ok(SnapshotDiff {
396            from: from.clone(),
397            to: to.clone(),
398            added_files,
399            modified_files,
400            deleted_files,
401        })
402    }
403
404    // Private helper methods
405
406    fn save_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
407        let snapshot_file = self.snapshots_path.join(format!("{}.json", snapshot.id.as_str()));
408        let content = serde_json::to_string_pretty(snapshot)?;
409        std::fs::write(snapshot_file, content)?;
410        Ok(())
411    }
412
413    fn save_branch(&self, branch: &Branch) -> Result<()> {
414        let mut branches = if self.branches_path.exists() {
415            let content = std::fs::read_to_string(&self.branches_path)?;
416            serde_json::from_str(&content)?
417        } else {
418            HashMap::new()
419        };
420
421        branches.insert(branch.name.clone(), branch.clone());
422
423        let content = serde_json::to_string_pretty(&branches)?;
424        std::fs::write(&self.branches_path, content)?;
425        Ok(())
426    }
427
428    fn get_branch_head(&self, name: &str) -> Result<Option<SnapshotId>> {
429        if !self.branches_path.exists() {
430            return Ok(None);
431        }
432
433        let content = std::fs::read_to_string(&self.branches_path)?;
434        let branches: HashMap<String, Branch> = serde_json::from_str(&content)?;
435        
436        Ok(branches.get(name).map(|b| b.head.clone()))
437    }
438
439    fn update_branch_head(&mut self, name: &str, head: SnapshotId) -> Result<()> {
440        let mut branches: HashMap<String, Branch> = if self.branches_path.exists() {
441            let content = std::fs::read_to_string(&self.branches_path)?;
442            serde_json::from_str(&content)?
443        } else {
444            HashMap::new()
445        };
446
447        if let Some(branch) = branches.get_mut(name) {
448            branch.head = head;
449            branch.updated_at = Utc::now();
450        } else {
451            branches.insert(name.to_string(), Branch {
452                name: name.to_string(),
453                head,
454                created_at: Utc::now(),
455                updated_at: Utc::now(),
456            });
457        }
458
459        let content = serde_json::to_string_pretty(&branches)?;
460        std::fs::write(&self.branches_path, content)?;
461        Ok(())
462    }
463
464    fn branch_exists(&self, name: &str) -> bool {
465        if !self.branches_path.exists() {
466            return false;
467        }
468
469        if let Ok(content) = std::fs::read_to_string(&self.branches_path) {
470            if let Ok(branches) = serde_json::from_str::<HashMap<String, Branch>>(&content) {
471                return branches.contains_key(name);
472            }
473        }
474
475        false
476    }
477}
478
479/// Diff between two snapshots
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct SnapshotDiff {
482    pub from: SnapshotId,
483    pub to: SnapshotId,
484    pub added_files: Vec<PathBuf>,
485    pub modified_files: Vec<PathBuf>,
486    pub deleted_files: Vec<PathBuf>,
487}
488
489impl SnapshotDiff {
490    /// Check if there are any changes
491    pub fn has_changes(&self) -> bool {
492        !self.added_files.is_empty() || 
493        !self.modified_files.is_empty() || 
494        !self.deleted_files.is_empty()
495    }
496
497    /// Count total changed files
498    pub fn total_changes(&self) -> usize {
499        self.added_files.len() + self.modified_files.len() + self.deleted_files.len()
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use tempfile::TempDir;
507
508    #[test]
509    fn test_create_snapshot() {
510        let temp_dir = TempDir::new().unwrap();
511        let mut manager = SnapshotManager::new(temp_dir.path()).unwrap();
512
513        let mut tool_states = HashMap::new();
514        tool_states.insert("test-tool".to_string(), ToolState {
515            tool_name: "test-tool".to_string(),
516            version: Version::new(1, 0, 0),
517            config: HashMap::new(),
518            output_files: vec![],
519        });
520
521        let id = manager.create_snapshot("Initial commit", tool_states, vec![]).unwrap();
522        
523        let snapshot = manager.get_snapshot(&id).unwrap().unwrap();
524        assert_eq!(snapshot.message, "Initial commit");
525        assert_eq!(snapshot.tool_states.len(), 1);
526    }
527
528    #[test]
529    fn test_branching() {
530        let temp_dir = TempDir::new().unwrap();
531        let mut manager = SnapshotManager::new(temp_dir.path()).unwrap();
532
533        // Create initial snapshot
534        manager.create_snapshot("Initial", HashMap::new(), vec![]).unwrap();
535
536        // Create a new branch
537        manager.create_branch("feature").unwrap();
538        
539        // Switch to new branch
540        manager.checkout_branch("feature").unwrap();
541        assert_eq!(manager.current_branch(), "feature");
542
543        // List branches
544        let branches = manager.list_branches().unwrap();
545        assert!(branches.iter().any(|b| b.name == "feature"));
546    }
547}