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}