ggen_core/
snapshot.rs

1//! Snapshot management for delta-driven projection
2//!
3//! This module provides functionality to create and manage snapshots of generation
4//! baselines, including RDF graph state, generated file states, and template metadata.
5//! Snapshots enable rollback, drift detection, and three-way merging.
6//!
7//! ## Features
8//!
9//! - **Graph Snapshots**: Capture RDF graph state with content hashing
10//! - **File Snapshots**: Track generated file content and metadata
11//! - **Template Snapshots**: Store template states and rendering context
12//! - **Region Tracking**: Identify generated vs manual regions in files
13//! - **Snapshot Management**: Create, load, and compare snapshots
14//! - **Drift Detection**: Detect when files have drifted from snapshots
15//!
16//! ## Snapshot Structure
17//!
18//! A snapshot contains:
19//! - Graph state (triple count, content hash)
20//! - File states (path, content hash, regions)
21//! - Template states (path, content hash, variables)
22//! - Metadata (creation time, description)
23//!
24//! ## Examples
25//!
26//! ### Creating a Snapshot
27//!
28//! ```rust,no_run
29//! use ggen_core::snapshot::{Snapshot, SnapshotManager};
30//! use ggen_core::graph::Graph;
31//! use std::path::PathBuf;
32//!
33//! # fn main() -> ggen_utils::error::Result<()> {
34//! let graph = Graph::new()?;
35//! let files = vec![(PathBuf::from("output.rs"), "content".to_string())];
36//! let templates = vec![(PathBuf::from("template.tmpl"), "template content".to_string())];
37//!
38//! let snapshot = Snapshot::new("baseline".to_string(), &graph, files, templates)?;
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! ### Managing Snapshots
44//!
45//! ```rust,no_run
46//! use ggen_core::snapshot::SnapshotManager;
47//! use std::path::Path;
48//!
49//! # fn main() -> ggen_utils::error::Result<()> {
50//! let manager = SnapshotManager::new(Path::new(".ggen/snapshots"));
51//! manager.save(&snapshot)?;
52//!
53//! let loaded = manager.load("baseline")?;
54//! # Ok(())
55//! # }
56//! ```
57
58use chrono::{DateTime, Utc};
59use ggen_utils::error::Result;
60use serde::{Deserialize, Serialize};
61use std::collections::BTreeMap;
62use std::fs::{self, File};
63use std::io::{BufReader, BufWriter};
64use std::path::{Path, PathBuf};
65
66use crate::graph::Graph;
67
68/// Represents a snapshot of a generation baseline
69///
70/// A `Snapshot` captures the complete state of a generation run, including:
71/// - Graph state (hash, triple count)
72/// - File states (paths, hashes, regions)
73/// - Template states (paths, hashes, queries)
74/// - Metadata (key-value pairs)
75///
76/// Snapshots enable rollback, drift detection, and three-way merging.
77///
78/// # Examples
79///
80/// ```rust,no_run
81/// use ggen_core::snapshot::Snapshot;
82/// use ggen_core::graph::Graph;
83/// use std::path::PathBuf;
84///
85/// # fn main() -> ggen_utils::error::Result<()> {
86/// let graph = Graph::new()?;
87/// graph.insert_turtle(r#"
88///     @prefix ex: <http://example.org/> .
89///     ex:alice a ex:Person .
90/// "#)?;
91///
92/// let files = vec![
93///     (PathBuf::from("output.rs"), "fn main() {}".to_string())
94/// ];
95/// let templates = vec![
96///     (PathBuf::from("template.tmpl"), "template content".to_string())
97/// ];
98///
99/// let snapshot = Snapshot::new("baseline".to_string(), &graph, files, templates)?;
100/// println!("Snapshot: {} with {} files", snapshot.name, snapshot.files.len());
101/// # Ok(())
102/// # }
103/// ```
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Snapshot {
106    /// Unique name for this snapshot
107    pub name: String,
108    /// When this snapshot was created
109    pub created_at: DateTime<Utc>,
110    /// Graph state information
111    pub graph: GraphSnapshot,
112    /// File state information
113    pub files: Vec<FileSnapshot>,
114    /// Associated templates
115    pub templates: Vec<TemplateSnapshot>,
116    /// Additional metadata
117    pub metadata: BTreeMap<String, String>,
118}
119
120impl Snapshot {
121    /// Create a new snapshot from current state
122    ///
123    /// Captures the current state of a graph, generated files, and templates
124    /// into a snapshot that can be used for rollback or drift detection.
125    ///
126    /// # Arguments
127    ///
128    /// * `name` - Unique name for this snapshot
129    /// * `graph` - Current RDF graph state
130    /// * `files` - Vector of (path, content) tuples for generated files
131    /// * `templates` - Vector of (path, content) tuples for templates
132    ///
133    /// # Examples
134    ///
135    /// ```rust
136    /// use ggen_core::snapshot::Snapshot;
137    /// use ggen_core::graph::Graph;
138    /// use std::path::PathBuf;
139    ///
140    /// let graph = Graph::new().unwrap();
141    /// let files = vec![(PathBuf::from("main.rs"), "fn main() {}".to_string())];
142    /// let templates = vec![];
143    ///
144    /// let snapshot = Snapshot::new("v1.0".to_string(), &graph, files, templates).unwrap();
145    /// assert_eq!(snapshot.name, "v1.0");
146    /// ```
147    pub fn new(
148        name: String, graph: &Graph, files: Vec<(PathBuf, String)>,
149        templates: Vec<(PathBuf, String)>,
150    ) -> Result<Self> {
151        let now = Utc::now();
152
153        let graph_snapshot = GraphSnapshot::from_graph(graph)?;
154
155        let file_snapshots = files
156            .into_iter()
157            .map(|(path, content)| FileSnapshot::new(path, content))
158            .collect::<Result<Vec<_>>>()?;
159
160        let template_snapshots = templates
161            .into_iter()
162            .map(|(path, content)| TemplateSnapshot::new(path, content))
163            .collect::<Result<Vec<_>>>()?;
164
165        Ok(Self {
166            name,
167            created_at: now,
168            graph: graph_snapshot,
169            files: file_snapshots,
170            templates: template_snapshots,
171            metadata: BTreeMap::new(),
172        })
173    }
174
175    /// Check if this snapshot is compatible with a graph
176    ///
177    /// Returns `true` if the graph's hash matches the snapshot's graph hash,
178    /// indicating the graph state hasn't changed since the snapshot was taken.
179    ///
180    /// # Examples
181    ///
182    /// ```rust,no_run
183    /// use ggen_core::snapshot::Snapshot;
184    /// use ggen_core::graph::Graph;
185    ///
186    /// # fn main() -> ggen_utils::error::Result<()> {
187    /// let graph = Graph::new()?;
188    /// let snapshot = Snapshot::new("test".to_string(), &graph, vec![], vec![])?;
189    ///
190    /// // Same graph should be compatible
191    /// assert!(snapshot.is_compatible_with(&graph)?);
192    ///
193    /// // Modified graph should not be compatible
194    /// graph.insert_turtle("@prefix ex: <http://example.org/> . ex:new a ex:Class .")?;
195    /// assert!(!snapshot.is_compatible_with(&graph)?);
196    /// # Ok(())
197    /// # }
198    /// ```
199    pub fn is_compatible_with(&self, graph: &Graph) -> Result<bool> {
200        let current_hash = graph.compute_hash()?;
201        Ok(self.graph.hash == current_hash)
202    }
203
204    /// Find a file snapshot by path
205    pub fn find_file(&self, path: &Path) -> Option<&FileSnapshot> {
206        self.files.iter().find(|f| f.path == path)
207    }
208
209    /// Find a template snapshot by path
210    pub fn find_template(&self, path: &Path) -> Option<&TemplateSnapshot> {
211        self.templates.iter().find(|t| t.path == path)
212    }
213
214    /// Add metadata to this snapshot
215    pub fn add_metadata(&mut self, key: String, value: String) {
216        self.metadata.insert(key, value);
217    }
218
219    /// Get metadata value
220    pub fn get_metadata(&self, key: &str) -> Option<&String> {
221        self.metadata.get(key)
222    }
223}
224
225/// Snapshot of graph state
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct GraphSnapshot {
228    /// Hash of the graph content
229    pub hash: String,
230    /// Number of triples in the graph
231    pub triple_count: usize,
232    /// Source files used to build the graph
233    pub sources: Vec<String>,
234    /// When the graph was loaded
235    pub loaded_at: DateTime<Utc>,
236}
237
238impl GraphSnapshot {
239    /// Create from a live graph
240    pub fn from_graph(graph: &Graph) -> Result<Self> {
241        let hash = graph.compute_hash()?;
242        let triple_count = graph.len();
243
244        Ok(Self {
245            hash,
246            triple_count,
247            sources: Vec::new(), // Would need to track source files
248            loaded_at: Utc::now(),
249        })
250    }
251}
252
253/// Snapshot of file state
254///
255/// Captures the state of a generated file, including its content hash,
256/// size, modification time, and regions (generated vs manual).
257///
258/// # Examples
259///
260/// ```rust,no_run
261/// use ggen_core::snapshot::FileSnapshot;
262/// use std::path::PathBuf;
263///
264/// # fn main() -> ggen_utils::error::Result<()> {
265/// let path = PathBuf::from("output.rs");
266/// let content = "fn main() { println!(\"Hello\"); }";
267///
268/// let snapshot = FileSnapshot::new(path.clone(), content.to_string())?;
269/// assert_eq!(snapshot.path, path);
270/// assert!(!snapshot.hash.is_empty());
271///
272/// // Check if content has changed
273/// assert!(!snapshot.has_changed(content));
274/// assert!(snapshot.has_changed("different content"));
275/// # Ok(())
276/// # }
277/// ```
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct FileSnapshot {
280    /// Path to the file
281    pub path: PathBuf,
282    /// Hash of the file content
283    pub hash: String,
284    /// File size in bytes
285    pub size: u64,
286    /// When the file was last modified
287    pub modified_at: DateTime<Utc>,
288    /// Generated regions in the file (for three-way merge)
289    pub generated_regions: Vec<Region>,
290    /// Manual regions in the file (for three-way merge)
291    pub manual_regions: Vec<Region>,
292}
293
294impl FileSnapshot {
295    /// Create from file path and content
296    pub fn new(path: PathBuf, content: String) -> Result<Self> {
297        let metadata = fs::metadata(&path)?;
298        let hash = Self::compute_hash(&content);
299
300        Ok(Self {
301            path,
302            hash,
303            size: metadata.len(),
304            modified_at: metadata.modified()?.into(),
305            generated_regions: Self::detect_regions(&content),
306            manual_regions: Self::detect_regions(&content),
307        })
308    }
309
310    /// Compute hash of content
311    fn compute_hash(content: &str) -> String {
312        use sha2::{Digest, Sha256};
313        let mut hasher = Sha256::new();
314        hasher.update(content.as_bytes());
315        format!("sha256:{:x}", hasher.finalize())
316    }
317
318    /// Detect generated and manual regions in content
319    /// This is a simplified implementation - real version would parse markers
320    fn detect_regions(_content: &str) -> Vec<Region> {
321        Vec::new() // Placeholder
322    }
323
324    /// Check if file content has changed
325    ///
326    /// Compares the hash of new content with the snapshot's hash.
327    /// Returns `true` if the content has changed since the snapshot was taken.
328    ///
329    /// # Examples
330    ///
331    /// ```rust,no_run
332    /// use ggen_core::snapshot::FileSnapshot;
333    /// use std::path::PathBuf;
334    ///
335    /// # fn main() -> ggen_utils::error::Result<()> {
336    /// let snapshot = FileSnapshot::new(
337    ///     PathBuf::from("file.rs"),
338    ///     "original content".to_string()
339    /// )?;
340    ///
341    /// assert!(!snapshot.has_changed("original content"));
342    /// assert!(snapshot.has_changed("modified content"));
343    /// # Ok(())
344    /// # }
345    /// ```
346    pub fn has_changed(&self, new_content: &str) -> bool {
347        let new_hash = Self::compute_hash(new_content);
348        self.hash != new_hash
349    }
350}
351
352/// Snapshot of template state
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct TemplateSnapshot {
355    /// Path to the template
356    pub path: PathBuf,
357    /// Hash of the template content
358    pub hash: String,
359    /// SPARQL queries in the template
360    pub queries: Vec<String>,
361    /// When the template was processed
362    pub processed_at: DateTime<Utc>,
363}
364
365impl TemplateSnapshot {
366    /// Create from template path and content
367    pub fn new(path: PathBuf, content: String) -> Result<Self> {
368        let hash = Self::compute_hash(&content);
369        let queries = Self::extract_queries(&content);
370
371        Ok(Self {
372            path,
373            hash,
374            queries,
375            processed_at: Utc::now(),
376        })
377    }
378
379    /// Compute hash of content
380    fn compute_hash(content: &str) -> String {
381        use sha2::{Digest, Sha256};
382        let mut hasher = Sha256::new();
383        hasher.update(content.as_bytes());
384        format!("sha256:{:x}", hasher.finalize())
385    }
386
387    /// Extract SPARQL queries from template (simplified)
388    fn extract_queries(_content: &str) -> Vec<String> {
389        Vec::new() // Placeholder - would parse frontmatter
390    }
391}
392
393/// Represents a region in a file (for three-way merge)
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct Region {
396    /// Start line (1-indexed)
397    pub start: usize,
398    /// End line (1-indexed)
399    pub end: usize,
400    /// Type of region
401    pub region_type: RegionType,
402}
403
404/// Type of region in a file
405///
406/// Represents whether a region in a file is generated or manually edited.
407///
408/// # Examples
409///
410/// ```rust
411/// use ggen_core::snapshot::RegionType;
412///
413/// # fn main() {
414/// let region_type = RegionType::Generated;
415/// match region_type {
416///     RegionType::Generated => assert!(true),
417///     RegionType::Manual => assert!(true),
418/// }
419/// # }
420/// ```
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
422pub enum RegionType {
423    /// Region contains generated content
424    Generated,
425    /// Region contains manual edits
426    Manual,
427}
428
429/// Manager for snapshot operations
430///
431/// Provides persistence and management for snapshots, including save, load,
432/// list, and delete operations.
433///
434/// # Examples
435///
436/// ```rust,no_run
437/// use ggen_core::snapshot::{SnapshotManager, Snapshot};
438/// use ggen_core::graph::Graph;
439/// use std::path::PathBuf;
440///
441/// # fn main() -> ggen_utils::error::Result<()> {
442/// let manager = SnapshotManager::new(PathBuf::from(".ggen/snapshots"))?;
443///
444/// let graph = Graph::new()?;
445/// let snapshot = Snapshot::new("baseline".to_string(), &graph, vec![], vec![])?;
446///
447/// // Save snapshot
448/// manager.save(&snapshot)?;
449///
450/// // Load snapshot
451/// let loaded = manager.load("baseline")?;
452/// assert_eq!(loaded.name, "baseline");
453///
454/// // List snapshots
455/// let snapshots = manager.list()?;
456/// assert!(snapshots.contains(&"baseline".to_string()));
457/// # Ok(())
458/// # }
459/// ```
460pub struct SnapshotManager {
461    /// Directory where snapshots are stored
462    snapshot_dir: PathBuf,
463}
464
465impl SnapshotManager {
466    /// Create a new snapshot manager
467    ///
468    /// Creates the snapshot directory if it doesn't exist.
469    ///
470    /// # Arguments
471    ///
472    /// * `snapshot_dir` - Directory path where snapshots will be stored
473    ///
474    /// # Examples
475    ///
476    /// ```rust,no_run
477    /// use ggen_core::snapshot::SnapshotManager;
478    /// use std::path::PathBuf;
479    ///
480    /// # fn main() -> ggen_utils::error::Result<()> {
481    /// let manager = SnapshotManager::new(PathBuf::from(".ggen/snapshots"))?;
482    /// // Directory is created automatically
483    /// # Ok(())
484    /// # }
485    /// ```
486    pub fn new(snapshot_dir: PathBuf) -> Result<Self> {
487        fs::create_dir_all(&snapshot_dir)?;
488        Ok(Self { snapshot_dir })
489    }
490
491    /// Save a snapshot to disk
492    ///
493    /// Persists a snapshot to a JSON file in the snapshot directory.
494    /// The file is named `{snapshot.name}.json`.
495    ///
496    /// # Examples
497    ///
498    /// ```rust,no_run
499    /// use ggen_core::snapshot::{SnapshotManager, Snapshot};
500    /// use ggen_core::graph::Graph;
501    /// use std::path::PathBuf;
502    ///
503    /// # fn main() -> ggen_utils::error::Result<()> {
504    /// let manager = SnapshotManager::new(PathBuf::from(".ggen/snapshots"))?;
505    /// let graph = Graph::new()?;
506    /// let snapshot = Snapshot::new("my-snapshot".to_string(), &graph, vec![], vec![])?;
507    ///
508    /// manager.save(&snapshot)?;
509    /// assert!(manager.exists("my-snapshot"));
510    /// # Ok(())
511    /// # }
512    /// ```
513    pub fn save(&self, snapshot: &Snapshot) -> Result<()> {
514        let file_path = self.snapshot_dir.join(format!("{}.json", snapshot.name));
515        let file = File::create(file_path)?;
516        let writer = BufWriter::new(file);
517        serde_json::to_writer_pretty(writer, snapshot)?;
518        Ok(())
519    }
520
521    /// Load a snapshot from disk
522    ///
523    /// Loads a snapshot from a JSON file in the snapshot directory.
524    ///
525    /// # Arguments
526    ///
527    /// * `name` - Name of the snapshot to load (without `.json` extension)
528    ///
529    /// # Errors
530    ///
531    /// Returns an error if the snapshot file doesn't exist or cannot be parsed.
532    ///
533    /// # Examples
534    ///
535    /// ```rust,no_run
536    /// use ggen_core::snapshot::{SnapshotManager, Snapshot};
537    /// use ggen_core::graph::Graph;
538    /// use std::path::PathBuf;
539    ///
540    /// # fn main() -> ggen_utils::error::Result<()> {
541    /// let manager = SnapshotManager::new(PathBuf::from(".ggen/snapshots"))?;
542    /// let graph = Graph::new()?;
543    /// let snapshot = Snapshot::new("test".to_string(), &graph, vec![], vec![])?;
544    /// manager.save(&snapshot)?;
545    ///
546    /// let loaded = manager.load("test")?;
547    /// assert_eq!(loaded.name, "test");
548    /// # Ok(())
549    /// # }
550    /// ```
551    pub fn load(&self, name: &str) -> Result<Snapshot> {
552        let file_path = self.snapshot_dir.join(format!("{}.json", name));
553        let file = File::open(file_path)?;
554        let reader = BufReader::new(file);
555        let snapshot = serde_json::from_reader(reader)?;
556        Ok(snapshot)
557    }
558
559    /// List all available snapshots
560    pub fn list(&self) -> Result<Vec<String>> {
561        let mut snapshots = Vec::new();
562        let entries = fs::read_dir(&self.snapshot_dir)?;
563
564        for entry in entries {
565            let entry = entry?;
566            let path = entry.path();
567            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
568                snapshots.push(name.to_string());
569            }
570        }
571
572        snapshots.sort();
573        Ok(snapshots)
574    }
575
576    /// Delete a snapshot
577    pub fn delete(&self, name: &str) -> Result<()> {
578        let file_path = self.snapshot_dir.join(format!("{}.json", name));
579        if file_path.exists() {
580            fs::remove_file(file_path)?;
581        }
582        Ok(())
583    }
584
585    /// Check if a snapshot exists
586    pub fn exists(&self, name: &str) -> bool {
587        let file_path = self.snapshot_dir.join(format!("{}.json", name));
588        file_path.exists()
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::graph::Graph;
596    use std::fs;
597    use tempfile::tempdir;
598
599    #[test]
600    fn test_snapshot_creation() {
601        let graph = Graph::new().unwrap();
602        graph
603            .insert_turtle("@prefix : <http://example.org/> . :test a :Class .")
604            .unwrap();
605
606        let temp_dir = tempdir().unwrap();
607        let test_file = temp_dir.path().join("test.txt");
608        let test_template = temp_dir.path().join("test.tmpl");
609
610        // Create the actual files
611        fs::write(&test_file, "test content").unwrap();
612        fs::write(&test_template, "template content").unwrap();
613
614        let files = vec![(test_file, "test content".to_string())];
615        let templates = vec![(test_template, "template content".to_string())];
616
617        let snapshot =
618            Snapshot::new("test_snapshot".to_string(), &graph, files, templates).unwrap();
619
620        assert_eq!(snapshot.name, "test_snapshot");
621        assert_eq!(snapshot.files.len(), 1);
622        assert_eq!(snapshot.templates.len(), 1);
623        assert!(!snapshot.graph.hash.is_empty());
624    }
625
626    #[test]
627    fn test_snapshot_manager() {
628        let temp_dir = tempdir().unwrap();
629        let manager = SnapshotManager::new(temp_dir.path().to_path_buf()).unwrap();
630
631        let graph = Graph::new().unwrap();
632        graph
633            .insert_turtle("@prefix : <http://example.org/> . :test a :Class .")
634            .unwrap();
635
636        let snapshot = Snapshot::new("manager_test".to_string(), &graph, vec![], vec![]).unwrap();
637
638        // Save snapshot
639        manager.save(&snapshot).unwrap();
640        assert!(manager.exists("manager_test"));
641
642        // Load snapshot
643        let loaded = manager.load("manager_test").unwrap();
644        assert_eq!(loaded.name, snapshot.name);
645
646        // List snapshots
647        let list = manager.list().unwrap();
648        assert!(list.contains(&"manager_test".to_string()));
649
650        // Delete snapshot
651        manager.delete("manager_test").unwrap();
652        assert!(!manager.exists("manager_test"));
653    }
654
655    #[test]
656    fn test_file_snapshot() {
657        let temp_dir = tempdir().unwrap();
658        let file_path = temp_dir.path().join("test.txt");
659        fs::write(&file_path, "test content").unwrap();
660
661        let snapshot = FileSnapshot::new(file_path.clone(), "test content".to_string()).unwrap();
662
663        assert_eq!(snapshot.path, file_path);
664        assert!(!snapshot.hash.is_empty());
665        assert!(snapshot.size > 0);
666
667        // Test change detection
668        assert!(!snapshot.has_changed("test content"));
669        assert!(snapshot.has_changed("different content"));
670    }
671
672    #[test]
673    fn test_template_snapshot() {
674        let temp_dir = tempdir().unwrap();
675        let template_path = temp_dir.path().join("test.tmpl");
676
677        let snapshot = TemplateSnapshot::new(
678            template_path.clone(),
679            "SELECT ?s WHERE { ?s ?p ?o }".to_string(),
680        )
681        .unwrap();
682
683        assert_eq!(snapshot.path, template_path);
684        assert!(!snapshot.hash.is_empty());
685    }
686}