1use 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
13const MAX_HISTORY_ENTRIES: usize = 50;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct HistoryEntry {
19 pub filename: String,
21 pub timestamp: String,
23 pub message: Option<String>,
25 pub node_count: usize,
27 pub edge_count: usize,
29 pub git_commit: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct GraphDiff {
36 pub added_nodes: Vec<String>,
38 pub removed_nodes: Vec<String>,
40 pub modified_nodes: Vec<String>,
42 pub added_edges: usize,
44 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
110pub struct HistoryManager {
112 history_dir: PathBuf,
113}
114
115impl HistoryManager {
116 pub fn new(gid_dir: &Path) -> Self {
118 Self {
119 history_dir: gid_dir.join("history"),
120 }
121 }
122
123 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 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 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 self.cleanup()?;
152
153 Ok(filename)
154 }
155
156 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 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 let timestamp = filename
180 .trim_end_matches(".yml")
181 .trim_end_matches(".yaml")
182 .replace('T', " ")
183 .replace('-', ":");
184
185 if let Ok(content) = fs::read_to_string(&filepath) {
187 let message = content.lines().next()
189 .filter(|l| l.starts_with("# "))
190 .map(|l| l[2..].to_string());
191
192 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, });
202 }
203 }
204 }
205
206 Ok(entries)
207 }
208
209 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 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 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 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 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 pub fn restore(&self, version: &str, graph_path: &Path) -> Result<()> {
278 let historical = self.load_version(version)?;
279
280 if graph_path.exists() {
282 if let Ok(current) = load_graph(graph_path) {
283 self.save_snapshot(¤t, Some("Auto-snapshot before restore"))?;
284 }
285 }
286
287 save_graph(&historical, graph_path)?;
289
290 Ok(())
291 }
292
293 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 files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
304
305 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}