Skip to main content

gid_core/
history.rs

1//! History tracking for GID graphs.
2//!
3//! Save snapshots with timestamps, list/diff/restore versions.
4
5use std::path::{Path, PathBuf};
6use std::fs;
7use anyhow::{Context, Result, bail};
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use crate::graph::Graph;
11use crate::parser::{load_graph, save_graph};
12
13/// Maximum number of history entries to keep.
14const MAX_HISTORY_ENTRIES: usize = 50;
15
16/// A history snapshot entry.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HistoryEntry {
19    /// Filename of the snapshot (e.g., "2024-03-25T12-30-00Z.yml")
20    pub filename: String,
21    /// ISO 8601 timestamp
22    pub timestamp: String,
23    /// Optional commit-like message
24    pub message: Option<String>,
25    /// Number of nodes in this snapshot
26    pub node_count: usize,
27    /// Number of edges in this snapshot
28    pub edge_count: usize,
29    /// Git commit hash if available
30    pub git_commit: Option<String>,
31}
32
33/// Diff result between two graph versions.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct GraphDiff {
36    /// Nodes added in the newer version
37    pub added_nodes: Vec<String>,
38    /// Nodes removed from the older version
39    pub removed_nodes: Vec<String>,
40    /// Nodes that changed (status, title, etc.)
41    pub modified_nodes: Vec<String>,
42    /// Number of edges added
43    pub added_edges: usize,
44    /// Number of edges removed
45    pub removed_edges: usize,
46}
47
48impl std::fmt::Display for GraphDiff {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        if self.is_empty() {
51            return write!(f, "No differences found.");
52        }
53        
54        let mut lines = Vec::new();
55        
56        if !self.added_nodes.is_empty() {
57            lines.push(format!("+ Added nodes ({}):", self.added_nodes.len()));
58            for node in self.added_nodes.iter().take(10) {
59                lines.push(format!("    + {}", node));
60            }
61            if self.added_nodes.len() > 10 {
62                lines.push(format!("    ... and {} more", self.added_nodes.len() - 10));
63            }
64        }
65        
66        if !self.removed_nodes.is_empty() {
67            lines.push(format!("- Removed nodes ({}):", self.removed_nodes.len()));
68            for node in self.removed_nodes.iter().take(10) {
69                lines.push(format!("    - {}", node));
70            }
71            if self.removed_nodes.len() > 10 {
72                lines.push(format!("    ... and {} more", self.removed_nodes.len() - 10));
73            }
74        }
75        
76        if !self.modified_nodes.is_empty() {
77            lines.push(format!("~ Modified nodes ({}):", self.modified_nodes.len()));
78            for node in self.modified_nodes.iter().take(10) {
79                lines.push(format!("    ~ {}", node));
80            }
81            if self.modified_nodes.len() > 10 {
82                lines.push(format!("    ... and {} more", self.modified_nodes.len() - 10));
83            }
84        }
85        
86        if self.added_edges > 0 || self.removed_edges > 0 {
87            lines.push("Edge changes:".to_string());
88            if self.added_edges > 0 {
89                lines.push(format!("    + {} edges added", self.added_edges));
90            }
91            if self.removed_edges > 0 {
92                lines.push(format!("    - {} edges removed", self.removed_edges));
93            }
94        }
95        
96        write!(f, "{}", lines.join("\n"))
97    }
98}
99
100impl GraphDiff {
101    pub fn is_empty(&self) -> bool {
102        self.added_nodes.is_empty()
103            && self.removed_nodes.is_empty()
104            && self.modified_nodes.is_empty()
105            && self.added_edges == 0
106            && self.removed_edges == 0
107    }
108}
109
110/// History manager for a GID project.
111pub struct HistoryManager {
112    history_dir: PathBuf,
113}
114
115impl HistoryManager {
116    /// Create a new history manager for the given .gid directory.
117    pub fn new(gid_dir: &Path) -> Self {
118        Self {
119            history_dir: gid_dir.join("history"),
120        }
121    }
122    
123    /// Ensure the history directory exists.
124    fn ensure_dir(&self) -> Result<()> {
125        if !self.history_dir.exists() {
126            fs::create_dir_all(&self.history_dir)
127                .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
128        }
129        Ok(())
130    }
131    
132    /// Save a snapshot of the current graph.
133    pub fn save_snapshot(&self, graph: &Graph, message: Option<&str>) -> Result<String> {
134        self.ensure_dir()?;
135        
136        let timestamp = Utc::now();
137        let filename = format!("{}.yml", timestamp.format("%Y-%m-%dT%H-%M-%SZ"));
138        let filepath = self.history_dir.join(&filename);
139        
140        // Add message as a comment at the top if provided
141        let yaml = if let Some(msg) = message {
142            format!("# {}\n{}", msg, serde_yaml::to_string(graph)?)
143        } else {
144            serde_yaml::to_string(graph)?
145        };
146        
147        fs::write(&filepath, yaml)
148            .with_context(|| format!("Failed to save snapshot: {}", filepath.display()))?;
149        
150        // Clean up old history entries
151        self.cleanup()?;
152        
153        Ok(filename)
154    }
155    
156    /// List all history snapshots.
157    pub fn list_snapshots(&self) -> Result<Vec<HistoryEntry>> {
158        if !self.history_dir.exists() {
159            return Ok(Vec::new());
160        }
161        
162        let mut entries = Vec::new();
163        
164        let mut files: Vec<_> = fs::read_dir(&self.history_dir)?
165            .filter_map(|e| e.ok())
166            .filter(|e| {
167                e.path().extension().map_or(false, |ext| ext == "yml" || ext == "yaml")
168            })
169            .collect();
170        
171        // Sort by filename (which includes timestamp) in descending order
172        files.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
173        
174        for entry in files {
175            let filepath = entry.path();
176            let filename = entry.file_name().to_string_lossy().to_string();
177            
178            // Extract timestamp from filename
179            let timestamp = filename
180                .trim_end_matches(".yml")
181                .trim_end_matches(".yaml")
182                .replace('T', " ")
183                .replace('-', ":");
184            
185            // Try to load the graph to get stats
186            if let Ok(content) = fs::read_to_string(&filepath) {
187                // Extract message from first line if it's a comment
188                let message = content.lines().next()
189                    .filter(|l| l.starts_with("# "))
190                    .map(|l| l[2..].to_string());
191                
192                // Parse the graph
193                if let Ok(graph) = serde_yaml::from_str::<Graph>(&content) {
194                    entries.push(HistoryEntry {
195                        filename,
196                        timestamp,
197                        message,
198                        node_count: graph.nodes.len(),
199                        edge_count: graph.edges.len(),
200                        git_commit: None, // TODO: Extract from metadata
201                    });
202                }
203            }
204        }
205        
206        Ok(entries)
207    }
208    
209    /// Load a historical version by filename.
210    pub fn load_version(&self, filename: &str) -> Result<Graph> {
211        let filepath = self.history_dir.join(filename);
212        
213        if !filepath.exists() {
214            bail!("History version not found: {}", filename);
215        }
216        
217        load_graph(&filepath)
218    }
219    
220    /// Compute diff between two graphs.
221    pub fn diff(older: &Graph, newer: &Graph) -> GraphDiff {
222        use std::collections::{HashMap, HashSet};
223        
224        let old_nodes: HashSet<&str> = older.nodes.iter().map(|n| n.id.as_str()).collect();
225        let new_nodes: HashSet<&str> = newer.nodes.iter().map(|n| n.id.as_str()).collect();
226        
227        let added_nodes: Vec<String> = new_nodes.difference(&old_nodes)
228            .map(|s| s.to_string())
229            .collect();
230        
231        let removed_nodes: Vec<String> = old_nodes.difference(&new_nodes)
232            .map(|s| s.to_string())
233            .collect();
234        
235        // Find modified nodes (same ID but different content)
236        let old_node_map: HashMap<&str, &crate::graph::Node> = 
237            older.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
238        let new_node_map: HashMap<&str, &crate::graph::Node> = 
239            newer.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
240        
241        let mut modified_nodes = Vec::new();
242        for id in old_nodes.intersection(&new_nodes) {
243            if let (Some(old), Some(new)) = (old_node_map.get(id), new_node_map.get(id)) {
244                if old.status != new.status || old.title != new.title || old.description != new.description {
245                    modified_nodes.push(id.to_string());
246                }
247            }
248        }
249        
250        // Edge comparison
251        let old_edges: HashSet<(&str, &str, &str)> = older.edges.iter()
252            .map(|e| (e.from.as_str(), e.to.as_str(), e.relation.as_str()))
253            .collect();
254        let new_edges: HashSet<(&str, &str, &str)> = newer.edges.iter()
255            .map(|e| (e.from.as_str(), e.to.as_str(), e.relation.as_str()))
256            .collect();
257        
258        let added_edges = new_edges.difference(&old_edges).count();
259        let removed_edges = old_edges.difference(&new_edges).count();
260        
261        GraphDiff {
262            added_nodes,
263            removed_nodes,
264            modified_nodes,
265            added_edges,
266            removed_edges,
267        }
268    }
269    
270    /// Diff current graph against a historical version.
271    pub fn diff_against(&self, version: &str, current: &Graph) -> Result<GraphDiff> {
272        let historical = self.load_version(version)?;
273        Ok(Self::diff(&historical, current))
274    }
275    
276    /// Restore a historical version to the main graph file.
277    pub fn restore(&self, version: &str, graph_path: &Path) -> Result<()> {
278        let historical = self.load_version(version)?;
279        
280        // Save current state to history first
281        if graph_path.exists() {
282            if let Ok(current) = load_graph(graph_path) {
283                self.save_snapshot(&current, Some("Auto-snapshot before restore"))?;
284            }
285        }
286        
287        // Write the historical version as the current graph
288        save_graph(&historical, graph_path)?;
289        
290        Ok(())
291    }
292    
293    /// Clean up old history entries, keeping only the most recent N.
294    fn cleanup(&self) -> Result<()> {
295        let mut files: Vec<_> = fs::read_dir(&self.history_dir)?
296            .filter_map(|e| e.ok())
297            .filter(|e| {
298                e.path().extension().map_or(false, |ext| ext == "yml" || ext == "yaml")
299            })
300            .collect();
301        
302        // Sort by filename in ascending order (oldest first)
303        files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
304        
305        // Remove oldest files if we have too many
306        while files.len() > MAX_HISTORY_ENTRIES {
307            if let Some(oldest) = files.first() {
308                fs::remove_file(oldest.path()).ok();
309                files.remove(0);
310            }
311        }
312        
313        Ok(())
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::graph::Node;
321    use tempfile::TempDir;
322    
323    #[test]
324    fn test_diff_empty_graphs() {
325        let g1 = Graph::new();
326        let g2 = Graph::new();
327        let diff = HistoryManager::diff(&g1, &g2);
328        assert!(diff.is_empty());
329    }
330    
331    #[test]
332    fn test_diff_added_nodes() {
333        let g1 = Graph::new();
334        let mut g2 = Graph::new();
335        g2.add_node(Node::new("a", "Node A"));
336        
337        let diff = HistoryManager::diff(&g1, &g2);
338        assert_eq!(diff.added_nodes, vec!["a"]);
339        assert!(diff.removed_nodes.is_empty());
340    }
341    
342    #[test]
343    fn test_save_and_load_snapshot() {
344        let temp = TempDir::new().unwrap();
345        let gid_dir = temp.path().join(".gid");
346        fs::create_dir_all(&gid_dir).unwrap();
347        
348        let mgr = HistoryManager::new(&gid_dir);
349        
350        let mut graph = Graph::new();
351        graph.add_node(Node::new("test", "Test Node"));
352        
353        let filename = mgr.save_snapshot(&graph, Some("Test snapshot")).unwrap();
354        
355        let loaded = mgr.load_version(&filename).unwrap();
356        assert_eq!(loaded.nodes.len(), 1);
357        assert_eq!(loaded.nodes[0].id, "test");
358    }
359}