1use std::path::{Path, PathBuf};
10use std::fs;
11use anyhow::{Context, Result, bail};
12use chrono::Utc;
13use serde::{Deserialize, Serialize};
14use crate::graph::Graph;
15use crate::parser::load_graph; use crate::storage::{load_graph_auto, save_graph_auto, StorageBackend}; #[cfg(feature = "sqlite")]
19use sha2::{Sha256, Digest};
20
21const MAX_HISTORY_ENTRIES: usize = 50;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HistoryEntry {
27 pub filename: String,
29 pub timestamp: String,
31 pub message: Option<String>,
33 pub node_count: usize,
35 pub edge_count: usize,
37 pub git_commit: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GraphDiff {
44 pub added_nodes: Vec<String>,
46 pub removed_nodes: Vec<String>,
48 pub modified_nodes: Vec<String>,
50 pub added_edges: usize,
52 pub removed_edges: usize,
54}
55
56impl std::fmt::Display for GraphDiff {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 if self.is_empty() {
59 return write!(f, "No differences found.");
60 }
61
62 let mut lines = Vec::new();
63
64 if !self.added_nodes.is_empty() {
65 lines.push(format!("+ Added nodes ({}):", self.added_nodes.len()));
66 for node in self.added_nodes.iter().take(10) {
67 lines.push(format!(" + {}", node));
68 }
69 if self.added_nodes.len() > 10 {
70 lines.push(format!(" ... and {} more", self.added_nodes.len() - 10));
71 }
72 }
73
74 if !self.removed_nodes.is_empty() {
75 lines.push(format!("- Removed nodes ({}):", self.removed_nodes.len()));
76 for node in self.removed_nodes.iter().take(10) {
77 lines.push(format!(" - {}", node));
78 }
79 if self.removed_nodes.len() > 10 {
80 lines.push(format!(" ... and {} more", self.removed_nodes.len() - 10));
81 }
82 }
83
84 if !self.modified_nodes.is_empty() {
85 lines.push(format!("~ Modified nodes ({}):", self.modified_nodes.len()));
86 for node in self.modified_nodes.iter().take(10) {
87 lines.push(format!(" ~ {}", node));
88 }
89 if self.modified_nodes.len() > 10 {
90 lines.push(format!(" ... and {} more", self.modified_nodes.len() - 10));
91 }
92 }
93
94 if self.added_edges > 0 || self.removed_edges > 0 {
95 lines.push("Edge changes:".to_string());
96 if self.added_edges > 0 {
97 lines.push(format!(" + {} edges added", self.added_edges));
98 }
99 if self.removed_edges > 0 {
100 lines.push(format!(" - {} edges removed", self.removed_edges));
101 }
102 }
103
104 write!(f, "{}", lines.join("\n"))
105 }
106}
107
108impl GraphDiff {
109 pub fn is_empty(&self) -> bool {
110 self.added_nodes.is_empty()
111 && self.removed_nodes.is_empty()
112 && self.modified_nodes.is_empty()
113 && self.added_edges == 0
114 && self.removed_edges == 0
115 }
116}
117
118pub struct HistoryManager {
120 history_dir: PathBuf,
121}
122
123impl HistoryManager {
124 pub fn new(gid_dir: &Path) -> Self {
126 Self {
127 history_dir: gid_dir.join("history"),
128 }
129 }
130
131 fn ensure_dir(&self) -> Result<()> {
133 if !self.history_dir.exists() {
134 fs::create_dir_all(&self.history_dir)
135 .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
136 }
137 Ok(())
138 }
139
140 pub fn save_snapshot(&self, graph: &Graph, message: Option<&str>) -> Result<String> {
142 let start = std::time::Instant::now();
143 self.ensure_dir()?;
144
145 let timestamp = Utc::now();
146 let filename = format!("{}.yml", timestamp.format("%Y-%m-%dT%H-%M-%SZ"));
147 let filepath = self.history_dir.join(&filename);
148
149 let yaml = if let Some(msg) = message {
151 format!("# {}\n{}", msg, serde_yaml::to_string(graph)?)
152 } else {
153 serde_yaml::to_string(graph)?
154 };
155
156 let file_size = yaml.len();
157 fs::write(&filepath, &yaml)
158 .with_context(|| format!("Failed to save snapshot: {}", filepath.display()))?;
159
160 self.cleanup()?;
162
163 let elapsed = start.elapsed();
164 tracing::info!(
165 filename = %filename,
166 file_size_bytes = file_size,
167 elapsed_ms = elapsed.as_millis() as u64,
168 "saved history snapshot"
169 );
170
171 Ok(filename)
172 }
173
174 #[cfg(feature = "sqlite")]
183 pub fn save_snapshot_sqlite(
184 &self,
185 db: &rusqlite::Connection,
186 message: Option<&str>,
187 ) -> Result<String> {
188 let start = std::time::Instant::now();
189 self.ensure_dir()?;
190
191 let timestamp = chrono::Utc::now();
192 let base = timestamp.format("%Y-%m-%dT%H-%M-%SZ").to_string();
194 let filename = {
195 let candidate = format!("{}.db", base);
196 if !self.history_dir.join(&candidate).exists() {
197 candidate
198 } else {
199 let mut suffix = 1;
200 loop {
201 let candidate = format!("{}-{}.db", base, suffix);
202 if !self.history_dir.join(&candidate).exists() {
203 break candidate;
204 }
205 suffix += 1;
206 }
207 }
208 };
209 let dest_path = self.history_dir.join(&filename);
210
211 let mut dest_conn = rusqlite::Connection::open(&dest_path)
213 .with_context(|| format!("Failed to open destination: {}", dest_path.display()))?;
214 {
215 let backup = rusqlite::backup::Backup::new(db, &mut dest_conn)
216 .with_context(|| "Failed to initialize SQLite backup")?;
217 backup
218 .run_to_completion(256, std::time::Duration::from_millis(50), None)
219 .with_context(|| "SQLite backup failed")?;
220 }
221 drop(dest_conn);
222
223 {
225 let verify_conn = rusqlite::Connection::open(&dest_path)?;
226 let integrity: String =
227 verify_conn.query_row("PRAGMA integrity_check", [], |r| r.get(0))?;
228 if integrity != "ok" {
229 fs::remove_file(&dest_path)?;
230 anyhow::bail!("Snapshot integrity check failed: {}", integrity);
231 }
232 }
233
234 let checksum = {
236 let mut file = std::fs::File::open(&dest_path)?;
237 let mut hasher = Sha256::new();
238 let mut buf = [0u8; 8192];
239 loop {
240 use std::io::Read;
241 let n = file.read(&mut buf)?;
242 if n == 0 {
243 break;
244 }
245 hasher.update(&buf[..n]);
246 }
247 format!("sha256:{:x}", hasher.finalize())
248 };
249
250 let file_size = fs::metadata(&dest_path)?.len();
251
252 if let Some(msg) = message {
254 let meta_path = dest_path.with_extension("db.meta");
255 let meta = serde_json::json!({
256 "message": msg,
257 "created_at": timestamp.to_rfc3339(),
258 "checksum": checksum,
259 "size_bytes": file_size,
260 });
261 fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
262 }
263
264 self.cleanup()?;
266
267 let elapsed = start.elapsed();
268 tracing::info!(
269 filename = %filename,
270 file_size_bytes = file_size,
271 checksum = %checksum,
272 elapsed_ms = elapsed.as_millis() as u64,
273 "saved SQLite history snapshot via backup API"
274 );
275
276 Ok(filename)
277 }
278
279 pub fn list_snapshots(&self) -> Result<Vec<HistoryEntry>> {
281 if !self.history_dir.exists() {
282 return Ok(Vec::new());
283 }
284
285 let mut entries = Vec::new();
286
287 let mut files: Vec<_> = fs::read_dir(&self.history_dir)?
288 .filter_map(|e| e.ok())
289 .filter(|e| {
290 e.path().extension().map_or(false, |ext| ext == "yml" || ext == "yaml")
291 })
292 .collect();
293
294 files.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
296
297 for entry in files {
298 let filepath = entry.path();
299 let filename = entry.file_name().to_string_lossy().to_string();
300
301 let timestamp = filename
303 .trim_end_matches(".yml")
304 .trim_end_matches(".yaml")
305 .replace('T', " ")
306 .replace('-', ":");
307
308 if let Ok(content) = fs::read_to_string(&filepath) {
310 let message = content.lines().next()
312 .filter(|l| l.starts_with("# "))
313 .map(|l| l[2..].to_string());
314
315 if let Ok(graph) = serde_yaml::from_str::<Graph>(&content) {
317 entries.push(HistoryEntry {
318 filename,
319 timestamp,
320 message,
321 node_count: graph.nodes.len(),
322 edge_count: graph.edges.len(),
323 git_commit: None, });
325 }
326 }
327 }
328
329 Ok(entries)
330 }
331
332 pub fn load_version(&self, filename: &str) -> Result<Graph> {
334 let filepath = self.history_dir.join(filename);
335
336 if !filepath.exists() {
337 bail!("History version not found: {}", filename);
338 }
339
340 load_graph(&filepath)
341 }
342
343 pub fn diff(older: &Graph, newer: &Graph) -> GraphDiff {
345 use std::collections::{HashMap, HashSet};
346
347 let old_nodes: HashSet<&str> = older.nodes.iter().map(|n| n.id.as_str()).collect();
348 let new_nodes: HashSet<&str> = newer.nodes.iter().map(|n| n.id.as_str()).collect();
349
350 let added_nodes: Vec<String> = new_nodes.difference(&old_nodes)
351 .map(|s| s.to_string())
352 .collect();
353
354 let removed_nodes: Vec<String> = old_nodes.difference(&new_nodes)
355 .map(|s| s.to_string())
356 .collect();
357
358 let old_node_map: HashMap<&str, &crate::graph::Node> =
360 older.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
361 let new_node_map: HashMap<&str, &crate::graph::Node> =
362 newer.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
363
364 let mut modified_nodes = Vec::new();
365 for id in old_nodes.intersection(&new_nodes) {
366 if let (Some(old), Some(new)) = (old_node_map.get(id), new_node_map.get(id)) {
367 if old.status != new.status || old.title != new.title || old.description != new.description {
368 modified_nodes.push(id.to_string());
369 }
370 }
371 }
372
373 let old_edges: HashSet<(&str, &str, &str)> = older.edges.iter()
375 .map(|e| (e.from.as_str(), e.to.as_str(), e.relation.as_str()))
376 .collect();
377 let new_edges: HashSet<(&str, &str, &str)> = newer.edges.iter()
378 .map(|e| (e.from.as_str(), e.to.as_str(), e.relation.as_str()))
379 .collect();
380
381 let added_edges = new_edges.difference(&old_edges).count();
382 let removed_edges = old_edges.difference(&new_edges).count();
383
384 GraphDiff {
385 added_nodes,
386 removed_nodes,
387 modified_nodes,
388 added_edges,
389 removed_edges,
390 }
391 }
392
393 pub fn diff_against(&self, version: &str, current: &Graph) -> Result<GraphDiff> {
395 let start = std::time::Instant::now();
396 let historical = self.load_version(version)?;
397 let diff = Self::diff(&historical, current);
398 let elapsed = start.elapsed();
399 tracing::info!(
400 version = %version,
401 added = diff.added_nodes.len(),
402 removed = diff.removed_nodes.len(),
403 modified = diff.modified_nodes.len(),
404 added_edges = diff.added_edges,
405 removed_edges = diff.removed_edges,
406 elapsed_ms = elapsed.as_millis() as u64,
407 "diff_against complete"
408 );
409 Ok(diff)
410 }
411
412 pub fn diff_versions(&self, version_a: &str, version_b: &str) -> Result<GraphDiff> {
414 let start = std::time::Instant::now();
415 let graph_a = self.load_version(version_a)?;
416 let graph_b = self.load_version(version_b)?;
417 let diff = Self::diff(&graph_a, &graph_b);
418 let elapsed = start.elapsed();
419 tracing::info!(
420 version_a = %version_a,
421 version_b = %version_b,
422 added = diff.added_nodes.len(),
423 removed = diff.removed_nodes.len(),
424 modified = diff.modified_nodes.len(),
425 added_edges = diff.added_edges,
426 removed_edges = diff.removed_edges,
427 elapsed_ms = elapsed.as_millis() as u64,
428 "diff_versions complete"
429 );
430 Ok(diff)
431 }
432
433 pub fn restore(&self, version: &str, gid_dir: &Path, backend: Option<StorageBackend>) -> Result<()> {
435 let start = std::time::Instant::now();
436 let historical = self.load_version(version)?;
437
438 if let Ok(current) = load_graph_auto(gid_dir, backend) {
440 if !current.nodes.is_empty() || !current.edges.is_empty() {
441 self.save_snapshot(¤t, Some("Auto-snapshot before restore"))?;
442 }
443 }
444
445 save_graph_auto(&historical, gid_dir, backend).map_err(|e| anyhow::anyhow!("{e}"))?;
447
448 let elapsed = start.elapsed();
449 tracing::info!(
450 version = %version,
451 elapsed_ms = elapsed.as_millis() as u64,
452 "restored historical version"
453 );
454
455 Ok(())
456 }
457
458 fn cleanup(&self) -> Result<()> {
460 let mut files: Vec<_> = fs::read_dir(&self.history_dir)?
461 .filter_map(|e| e.ok())
462 .filter(|e| {
463 e.path().extension().map_or(false, |ext| ext == "yml" || ext == "yaml")
464 })
465 .collect();
466
467 files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
469
470 while files.len() > MAX_HISTORY_ENTRIES {
472 if let Some(oldest) = files.first() {
473 fs::remove_file(oldest.path()).ok();
474 files.remove(0);
475 }
476 }
477
478 Ok(())
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::graph::{Node, Edge, NodeStatus, ProjectMeta};
486 use crate::parser::save_graph;
487 use tempfile::TempDir;
488
489 #[test]
490 fn test_diff_empty_graphs() {
491 let g1 = Graph::new();
492 let g2 = Graph::new();
493 let diff = HistoryManager::diff(&g1, &g2);
494 assert!(diff.is_empty());
495 }
496
497 #[test]
498 fn test_diff_added_nodes() {
499 let g1 = Graph::new();
500 let mut g2 = Graph::new();
501 g2.add_node(Node::new("a", "Node A"));
502
503 let diff = HistoryManager::diff(&g1, &g2);
504 assert_eq!(diff.added_nodes, vec!["a"]);
505 assert!(diff.removed_nodes.is_empty());
506 }
507
508 #[test]
509 fn test_save_and_load_snapshot() {
510 let temp = TempDir::new().unwrap();
511 let gid_dir = temp.path().join(".gid");
512 fs::create_dir_all(&gid_dir).unwrap();
513
514 let mgr = HistoryManager::new(&gid_dir);
515
516 let mut graph = Graph::new();
517 graph.add_node(Node::new("test", "Test Node"));
518
519 let filename = mgr.save_snapshot(&graph, Some("Test snapshot")).unwrap();
520
521 let loaded = mgr.load_version(&filename).unwrap();
522 assert_eq!(loaded.nodes.len(), 1);
523 assert_eq!(loaded.nodes[0].id, "test");
524 }
525
526 #[test]
527 fn test_save_prunes_old_snapshots() {
528 let temp = TempDir::new().unwrap();
529 let gid_dir = temp.path().join(".gid");
530 fs::create_dir_all(&gid_dir).unwrap();
531
532 let mgr = HistoryManager::new(&gid_dir);
533 let graph = Graph::new();
534
535 for i in 0..(MAX_HISTORY_ENTRIES + 5) {
537 let ts = format!("2024-01-01T00-00-{:02}Z.yml", i);
538 let path = mgr.history_dir.join(&ts);
539 fs::create_dir_all(&mgr.history_dir).unwrap();
540 fs::write(&path, serde_yaml::to_string(&graph).unwrap()).unwrap();
541 }
542
543 mgr.save_snapshot(&graph, Some("trigger prune")).unwrap();
545
546 let count = fs::read_dir(&mgr.history_dir)
548 .unwrap()
549 .filter_map(|e| e.ok())
550 .filter(|e| e.path().extension().map_or(false, |ext| ext == "yml"))
551 .count();
552
553 assert!(
554 count <= MAX_HISTORY_ENTRIES,
555 "Expected at most {} snapshots after prune, got {}",
556 MAX_HISTORY_ENTRIES,
557 count
558 );
559 }
560
561 #[test]
564 fn test_roundtrip_graph_with_all_fields() {
565 let temp = TempDir::new().unwrap();
566 let gid_dir = temp.path().join(".gid");
567 fs::create_dir_all(&gid_dir).unwrap();
568 let mgr = HistoryManager::new(&gid_dir);
569
570 let mut graph = Graph::new();
571 graph.project = Some(ProjectMeta {
572 name: "roundtrip-test".to_string(),
573 description: Some("Full field roundtrip".to_string()),
574 });
575
576 let mut node = Node::new("task-1", "Implement feature X")
577 .with_description("A complex task with all fields populated")
578 .with_status(NodeStatus::InProgress)
579 .with_tags(vec!["rust".to_string(), "backend".to_string()])
580 .with_priority(10);
581 node.node_type = Some("task".to_string());
582 node.assigned_to = Some("potato".to_string());
583 node.file_path = Some("src/main.rs".to_string());
584 node.lang = Some("rust".to_string());
585 node.start_line = Some(42);
586 node.end_line = Some(100);
587 node.signature = Some("fn main() -> Result<()>".to_string());
588 node.visibility = Some("public".to_string());
589 node.doc_comment = Some("/// Entry point".to_string());
590 node.body_hash = Some("abc123".to_string());
591 node.node_kind = Some("function".to_string());
592 node.owner = Some("team-alpha".to_string());
593 node.source = Some("manual".to_string());
594 node.repo = Some("gid-rs".to_string());
595 node.parent_id = Some("feature-1".to_string());
596 node.depth = Some(2);
597 node.complexity = Some(7.5);
598 node.is_public = Some(true);
599 node.body = Some("fn main() { println!(\"hello\"); }".to_string());
600 node.created_at = Some("2026-01-01T00:00:00Z".to_string());
601 node.updated_at = Some("2026-04-08T00:00:00Z".to_string());
602 node.metadata.insert("custom_key".to_string(), serde_json::json!("custom_value"));
603 graph.add_node(node);
604
605 let mut edge = Edge::new("task-1", "task-2", "depends_on");
606 edge.weight = Some(0.9);
607 edge.confidence = Some(0.85);
608 edge.metadata = Some(serde_json::json!({"source": "extract"}));
609 graph.add_edge(edge);
610
611 let filename = mgr.save_snapshot(&graph, Some("All fields test")).unwrap();
612 let loaded = mgr.load_version(&filename).unwrap();
613
614 let proj = loaded.project.as_ref().unwrap();
616 assert_eq!(proj.name, "roundtrip-test");
617 assert_eq!(proj.description.as_deref(), Some("Full field roundtrip"));
618
619 assert_eq!(loaded.nodes.len(), 1);
621 let n = &loaded.nodes[0];
622 assert_eq!(n.id, "task-1");
623 assert_eq!(n.title, "Implement feature X");
624 assert_eq!(n.status, NodeStatus::InProgress);
625 assert_eq!(n.description.as_deref(), Some("A complex task with all fields populated"));
626 assert_eq!(n.tags, vec!["rust", "backend"]);
627 assert_eq!(n.priority, Some(10));
628 assert_eq!(n.assigned_to.as_deref(), Some("potato"));
629 assert_eq!(n.file_path.as_deref(), Some("src/main.rs"));
630 assert_eq!(n.lang.as_deref(), Some("rust"));
631 assert_eq!(n.start_line, Some(42));
632 assert_eq!(n.end_line, Some(100));
633 assert_eq!(n.signature.as_deref(), Some("fn main() -> Result<()>"));
634 assert_eq!(n.visibility.as_deref(), Some("public"));
635 assert_eq!(n.doc_comment.as_deref(), Some("/// Entry point"));
636 assert_eq!(n.body_hash.as_deref(), Some("abc123"));
637 assert_eq!(n.node_kind.as_deref(), Some("function"));
638 assert_eq!(n.owner.as_deref(), Some("team-alpha"));
639 assert_eq!(n.source.as_deref(), Some("manual"));
640 assert_eq!(n.repo.as_deref(), Some("gid-rs"));
641 assert_eq!(n.parent_id.as_deref(), Some("feature-1"));
642 assert_eq!(n.depth, Some(2));
643 assert_eq!(n.complexity, Some(7.5));
644 assert_eq!(n.is_public, Some(true));
645 assert_eq!(n.body.as_deref(), Some("fn main() { println!(\"hello\"); }"));
646 assert_eq!(n.created_at.as_deref(), Some("2026-01-01T00:00:00Z"));
647 assert_eq!(n.updated_at.as_deref(), Some("2026-04-08T00:00:00Z"));
648 assert_eq!(n.metadata.get("custom_key").unwrap(), &serde_json::json!("custom_value"));
649
650 assert_eq!(loaded.edges.len(), 1);
652 let e = &loaded.edges[0];
653 assert_eq!(e.from, "task-1");
654 assert_eq!(e.to, "task-2");
655 assert_eq!(e.relation, "depends_on");
656 assert_eq!(e.weight, Some(0.9));
657 assert_eq!(e.confidence, Some(0.85));
658 assert_eq!(e.source(), Some("extract"));
659 }
660
661 #[test]
662 fn test_roundtrip_unicode_content() {
663 let temp = TempDir::new().unwrap();
664 let gid_dir = temp.path().join(".gid");
665 fs::create_dir_all(&gid_dir).unwrap();
666 let mgr = HistoryManager::new(&gid_dir);
667
668 let mut graph = Graph::new();
669 graph.add_node(
670 Node::new("unicode-1", "实现功能 X — 中文标题")
671 .with_description("描述包含 emoji 🚀 和日文 こんにちは")
672 .with_tags(vec!["标签一".to_string(), "タグ".to_string()])
673 );
674 graph.add_edge(Edge::new("unicode-1", "unicode-2", "関連"));
675
676 let filename = mgr.save_snapshot(&graph, Some("Unicode テスト 🎉")).unwrap();
677 let loaded = mgr.load_version(&filename).unwrap();
678
679 assert_eq!(loaded.nodes[0].title, "实现功能 X — 中文标题");
680 assert_eq!(loaded.nodes[0].description.as_deref(), Some("描述包含 emoji 🚀 和日文 こんにちは"));
681 assert_eq!(loaded.nodes[0].tags, vec!["标签一", "タグ"]);
682 assert_eq!(loaded.edges[0].relation, "関連");
683 }
684
685 #[test]
686 fn test_roundtrip_empty_graph() {
687 let temp = TempDir::new().unwrap();
688 let gid_dir = temp.path().join(".gid");
689 fs::create_dir_all(&gid_dir).unwrap();
690 let mgr = HistoryManager::new(&gid_dir);
691
692 let graph = Graph::new();
693 let filename = mgr.save_snapshot(&graph, None).unwrap();
694 let loaded = mgr.load_version(&filename).unwrap();
695
696 assert!(loaded.nodes.is_empty());
697 assert!(loaded.edges.is_empty());
698 }
699
700 #[test]
701 fn test_roundtrip_multiple_sequential_snapshots() {
702 let temp = TempDir::new().unwrap();
703 let gid_dir = temp.path().join(".gid");
704 fs::create_dir_all(&gid_dir).unwrap();
705 let mgr = HistoryManager::new(&gid_dir);
706
707 let mut graph = Graph::new();
709 let f1 = mgr.save_snapshot(&graph, Some("v1: empty")).unwrap();
710
711 graph.add_node(Node::new("a", "Alpha"));
713 std::thread::sleep(std::time::Duration::from_millis(1100));
715 let f2 = mgr.save_snapshot(&graph, Some("v2: one node")).unwrap();
716
717 graph.add_node(Node::new("b", "Beta"));
719 graph.add_edge(Edge::depends_on("b", "a"));
720 std::thread::sleep(std::time::Duration::from_millis(1100));
721 let f3 = mgr.save_snapshot(&graph, Some("v3: two nodes")).unwrap();
722
723 assert_ne!(f1, f2);
725 assert_ne!(f2, f3);
726
727 let g1 = mgr.load_version(&f1).unwrap();
729 let g2 = mgr.load_version(&f2).unwrap();
730 let g3 = mgr.load_version(&f3).unwrap();
731
732 assert_eq!(g1.nodes.len(), 0);
733 assert_eq!(g2.nodes.len(), 1);
734 assert_eq!(g3.nodes.len(), 2);
735 assert_eq!(g3.edges.len(), 1);
736 }
737
738 #[test]
739 fn test_snapshot_message_preserved() {
740 let temp = TempDir::new().unwrap();
741 let gid_dir = temp.path().join(".gid");
742 fs::create_dir_all(&gid_dir).unwrap();
743 let mgr = HistoryManager::new(&gid_dir);
744
745 let graph = Graph::new();
746 let _f = mgr.save_snapshot(&graph, Some("Release v1.0.0")).unwrap();
747
748 let entries = mgr.list_snapshots().unwrap();
749 assert_eq!(entries.len(), 1);
750 assert_eq!(entries[0].message.as_deref(), Some("Release v1.0.0"));
751 }
752
753 #[test]
754 fn test_snapshot_no_message() {
755 let temp = TempDir::new().unwrap();
756 let gid_dir = temp.path().join(".gid");
757 fs::create_dir_all(&gid_dir).unwrap();
758 let mgr = HistoryManager::new(&gid_dir);
759
760 let graph = Graph::new();
761 mgr.save_snapshot(&graph, None).unwrap();
762
763 let entries = mgr.list_snapshots().unwrap();
764 assert_eq!(entries.len(), 1);
765 assert!(entries[0].message.is_none());
766 }
767
768 #[test]
769 fn test_snapshot_node_edge_counts_in_listing() {
770 let temp = TempDir::new().unwrap();
771 let gid_dir = temp.path().join(".gid");
772 fs::create_dir_all(&gid_dir).unwrap();
773 let mgr = HistoryManager::new(&gid_dir);
774
775 let mut graph = Graph::new();
776 graph.add_node(Node::new("a", "A"));
777 graph.add_node(Node::new("b", "B"));
778 graph.add_node(Node::new("c", "C"));
779 graph.add_edge(Edge::depends_on("b", "a"));
780 graph.add_edge(Edge::depends_on("c", "b"));
781
782 mgr.save_snapshot(&graph, None).unwrap();
783
784 let entries = mgr.list_snapshots().unwrap();
785 assert_eq!(entries[0].node_count, 3);
786 assert_eq!(entries[0].edge_count, 2);
787 }
788
789 #[test]
792 fn test_list_empty_directory() {
793 let temp = TempDir::new().unwrap();
794 let gid_dir = temp.path().join(".gid");
795 let mgr = HistoryManager::new(&gid_dir);
797 let entries = mgr.list_snapshots().unwrap();
798 assert!(entries.is_empty());
799 }
800
801 #[test]
802 fn test_list_chronological_order() {
803 let temp = TempDir::new().unwrap();
804 let gid_dir = temp.path().join(".gid");
805 let history_dir = gid_dir.join("history");
806 fs::create_dir_all(&history_dir).unwrap();
807 let mgr = HistoryManager::new(&gid_dir);
808
809 let graph = Graph::new();
810 let names = vec![
812 "2026-01-01T00-00-00Z.yml",
813 "2026-03-15T12-30-00Z.yml",
814 "2026-04-08T23-59-59Z.yml",
815 ];
816 for name in &names {
817 let path = history_dir.join(name);
818 fs::write(&path, serde_yaml::to_string(&graph).unwrap()).unwrap();
819 }
820
821 let entries = mgr.list_snapshots().unwrap();
822 assert_eq!(entries.len(), 3);
823 assert_eq!(entries[0].filename, "2026-04-08T23-59-59Z.yml");
825 assert_eq!(entries[1].filename, "2026-03-15T12-30-00Z.yml");
826 assert_eq!(entries[2].filename, "2026-01-01T00-00-00Z.yml");
827 }
828
829 #[test]
830 fn test_list_ignores_non_yaml_files() {
831 let temp = TempDir::new().unwrap();
832 let gid_dir = temp.path().join(".gid");
833 let history_dir = gid_dir.join("history");
834 fs::create_dir_all(&history_dir).unwrap();
835 let mgr = HistoryManager::new(&gid_dir);
836
837 let graph = Graph::new();
838 let yaml_content = serde_yaml::to_string(&graph).unwrap();
839
840 fs::write(history_dir.join("2026-01-01T00-00-00Z.yml"), &yaml_content).unwrap();
841 fs::write(history_dir.join("notes.txt"), "not a snapshot").unwrap();
842 fs::write(history_dir.join("backup.json"), "{}").unwrap();
843 fs::write(history_dir.join(".hidden"), "hidden file").unwrap();
844
845 let entries = mgr.list_snapshots().unwrap();
846 assert_eq!(entries.len(), 1);
847 assert_eq!(entries[0].filename, "2026-01-01T00-00-00Z.yml");
848 }
849
850 #[test]
851 fn test_list_does_not_prune() {
852 let temp = TempDir::new().unwrap();
853 let gid_dir = temp.path().join(".gid");
854 let history_dir = gid_dir.join("history");
855 fs::create_dir_all(&history_dir).unwrap();
856
857 let mgr = HistoryManager::new(&gid_dir);
858 let graph = Graph::new();
859
860 let total = MAX_HISTORY_ENTRIES + 3;
862 for i in 0..total {
863 let ts = format!("2024-01-01T00-00-{:02}Z.yml", i);
864 let path = history_dir.join(&ts);
865 fs::write(&path, serde_yaml::to_string(&graph).unwrap()).unwrap();
866 }
867
868 let entries = mgr.list_snapshots().unwrap();
870 assert_eq!(entries.len(), total);
871
872 let count = fs::read_dir(&history_dir)
874 .unwrap()
875 .filter_map(|e| e.ok())
876 .filter(|e| e.path().extension().map_or(false, |ext| ext == "yml"))
877 .count();
878 assert_eq!(count, total, "list_snapshots should not prune files");
879 }
880
881 #[test]
884 fn test_diff_removed_nodes() {
885 let mut g1 = Graph::new();
886 g1.add_node(Node::new("a", "Alpha"));
887 g1.add_node(Node::new("b", "Beta"));
888
889 let mut g2 = Graph::new();
890 g2.add_node(Node::new("a", "Alpha"));
891
892 let diff = HistoryManager::diff(&g1, &g2);
893 assert!(diff.added_nodes.is_empty());
894 assert_eq!(diff.removed_nodes, vec!["b"]);
895 assert!(diff.modified_nodes.is_empty());
896 }
897
898 #[test]
899 fn test_diff_modified_status() {
900 let mut g1 = Graph::new();
901 g1.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Todo));
902
903 let mut g2 = Graph::new();
904 g2.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Done));
905
906 let diff = HistoryManager::diff(&g1, &g2);
907 assert!(diff.added_nodes.is_empty());
908 assert!(diff.removed_nodes.is_empty());
909 assert_eq!(diff.modified_nodes, vec!["a"]);
910 }
911
912 #[test]
913 fn test_diff_modified_title() {
914 let mut g1 = Graph::new();
915 g1.add_node(Node::new("a", "Old Title"));
916
917 let mut g2 = Graph::new();
918 g2.add_node(Node::new("a", "New Title"));
919
920 let diff = HistoryManager::diff(&g1, &g2);
921 assert_eq!(diff.modified_nodes, vec!["a"]);
922 }
923
924 #[test]
925 fn test_diff_modified_description() {
926 let mut g1 = Graph::new();
927 g1.add_node(Node::new("a", "Alpha").with_description("Old desc"));
928
929 let mut g2 = Graph::new();
930 g2.add_node(Node::new("a", "Alpha").with_description("New desc"));
931
932 let diff = HistoryManager::diff(&g1, &g2);
933 assert_eq!(diff.modified_nodes, vec!["a"]);
934 }
935
936 #[test]
937 fn test_diff_unchanged_nodes_not_reported() {
938 let mut g1 = Graph::new();
939 g1.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Todo).with_description("Same desc"));
940
941 let mut g2 = Graph::new();
942 g2.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Todo).with_description("Same desc"));
943
944 let diff = HistoryManager::diff(&g1, &g2);
945 assert!(diff.is_empty());
946 }
947
948 #[test]
949 fn test_diff_added_edges() {
950 let mut g1 = Graph::new();
951 g1.add_node(Node::new("a", "A"));
952 g1.add_node(Node::new("b", "B"));
953
954 let mut g2 = Graph::new();
955 g2.add_node(Node::new("a", "A"));
956 g2.add_node(Node::new("b", "B"));
957 g2.add_edge(Edge::depends_on("b", "a"));
958
959 let diff = HistoryManager::diff(&g1, &g2);
960 assert_eq!(diff.added_edges, 1);
961 assert_eq!(diff.removed_edges, 0);
962 }
963
964 #[test]
965 fn test_diff_removed_edges() {
966 let mut g1 = Graph::new();
967 g1.add_node(Node::new("a", "A"));
968 g1.add_node(Node::new("b", "B"));
969 g1.add_edge(Edge::depends_on("b", "a"));
970 g1.add_edge(Edge::new("a", "b", "relates_to"));
971
972 let mut g2 = Graph::new();
973 g2.add_node(Node::new("a", "A"));
974 g2.add_node(Node::new("b", "B"));
975
976 let diff = HistoryManager::diff(&g1, &g2);
977 assert_eq!(diff.added_edges, 0);
978 assert_eq!(diff.removed_edges, 2);
979 }
980
981 #[test]
982 fn test_diff_edge_relation_change_counts_as_add_and_remove() {
983 let mut g1 = Graph::new();
984 g1.add_node(Node::new("a", "A"));
985 g1.add_node(Node::new("b", "B"));
986 g1.add_edge(Edge::new("a", "b", "depends_on"));
987
988 let mut g2 = Graph::new();
989 g2.add_node(Node::new("a", "A"));
990 g2.add_node(Node::new("b", "B"));
991 g2.add_edge(Edge::new("a", "b", "blocks"));
992
993 let diff = HistoryManager::diff(&g1, &g2);
994 assert_eq!(diff.added_edges, 1);
996 assert_eq!(diff.removed_edges, 1);
997 }
998
999 #[test]
1000 fn test_diff_complex_mixed_changes() {
1001 let mut g1 = Graph::new();
1002 g1.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Todo));
1003 g1.add_node(Node::new("b", "Beta"));
1004 g1.add_node(Node::new("c", "Gamma")); g1.add_edge(Edge::depends_on("b", "a"));
1006
1007 let mut g2 = Graph::new();
1008 g2.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Done)); g2.add_node(Node::new("b", "Beta")); g2.add_node(Node::new("d", "Delta")); g2.add_edge(Edge::depends_on("d", "a")); let diff = HistoryManager::diff(&g1, &g2);
1014 assert!(diff.added_nodes.contains(&"d".to_string()));
1015 assert!(diff.removed_nodes.contains(&"c".to_string()));
1016 assert!(diff.modified_nodes.contains(&"a".to_string()));
1017 assert!(!diff.modified_nodes.contains(&"b".to_string()));
1018 assert_eq!(diff.added_edges, 1);
1019 assert_eq!(diff.removed_edges, 1);
1020 assert!(!diff.is_empty());
1021 }
1022
1023 #[test]
1024 fn test_diff_display_format_added() {
1025 let g1 = Graph::new();
1026 let mut g2 = Graph::new();
1027 g2.add_node(Node::new("a", "A"));
1028
1029 let diff = HistoryManager::diff(&g1, &g2);
1030 let display = format!("{}", diff);
1031 assert!(display.contains("Added nodes (1)"));
1032 assert!(display.contains("+ a"));
1033 }
1034
1035 #[test]
1036 fn test_diff_display_format_removed() {
1037 let mut g1 = Graph::new();
1038 g1.add_node(Node::new("a", "A"));
1039 let g2 = Graph::new();
1040
1041 let diff = HistoryManager::diff(&g1, &g2);
1042 let display = format!("{}", diff);
1043 assert!(display.contains("Removed nodes (1)"));
1044 assert!(display.contains("- a"));
1045 }
1046
1047 #[test]
1048 fn test_diff_display_format_modified() {
1049 let mut g1 = Graph::new();
1050 g1.add_node(Node::new("a", "Old Title"));
1051 let mut g2 = Graph::new();
1052 g2.add_node(Node::new("a", "New Title"));
1053
1054 let diff = HistoryManager::diff(&g1, &g2);
1055 let display = format!("{}", diff);
1056 assert!(display.contains("Modified nodes (1)"));
1057 assert!(display.contains("~ a"));
1058 }
1059
1060 #[test]
1061 fn test_diff_display_format_edges() {
1062 let mut g1 = Graph::new();
1063 g1.add_edge(Edge::depends_on("a", "b"));
1064 let g2 = Graph::new();
1065
1066 let diff = HistoryManager::diff(&g1, &g2);
1067 let display = format!("{}", diff);
1068 assert!(display.contains("Edge changes:"));
1069 assert!(display.contains("1 edges removed"));
1070 }
1071
1072 #[test]
1073 fn test_diff_display_empty() {
1074 let g1 = Graph::new();
1075 let g2 = Graph::new();
1076 let diff = HistoryManager::diff(&g1, &g2);
1077 let display = format!("{}", diff);
1078 assert_eq!(display, "No differences found.");
1079 }
1080
1081 #[test]
1082 fn test_diff_display_truncates_at_10() {
1083 let g1 = Graph::new();
1084 let mut g2 = Graph::new();
1085 for i in 0..15 {
1086 g2.add_node(Node::new(&format!("node-{}", i), &format!("Node {}", i)));
1087 }
1088
1089 let diff = HistoryManager::diff(&g1, &g2);
1090 let display = format!("{}", diff);
1091 assert!(display.contains("... and 5 more"));
1092 }
1093
1094 #[test]
1095 fn test_diff_against_historical_version() {
1096 let temp = TempDir::new().unwrap();
1097 let gid_dir = temp.path().join(".gid");
1098 fs::create_dir_all(&gid_dir).unwrap();
1099 let mgr = HistoryManager::new(&gid_dir);
1100
1101 let mut old_graph = Graph::new();
1102 old_graph.add_node(Node::new("a", "A"));
1103
1104 let filename = mgr.save_snapshot(&old_graph, Some("v1")).unwrap();
1105
1106 let mut current = Graph::new();
1107 current.add_node(Node::new("a", "A"));
1108 current.add_node(Node::new("b", "B"));
1109
1110 let diff = mgr.diff_against(&filename, ¤t).unwrap();
1111 assert_eq!(diff.added_nodes, vec!["b"]);
1112 assert!(diff.removed_nodes.is_empty());
1113 }
1114
1115 #[test]
1118 fn test_restore_overwrites_current_graph() {
1119 let temp = TempDir::new().unwrap();
1120 let gid_dir = temp.path().join(".gid");
1121 fs::create_dir_all(&gid_dir).unwrap();
1122 let mgr = HistoryManager::new(&gid_dir);
1123 let graph_path = gid_dir.join("graph.yml");
1124
1125 let mut v1 = Graph::new();
1127 v1.add_node(Node::new("a", "Alpha"));
1128 let v1_file = mgr.save_snapshot(&v1, Some("v1")).unwrap();
1129
1130 let mut v2 = Graph::new();
1132 v2.add_node(Node::new("b", "Beta"));
1133 save_graph(&v2, &graph_path).unwrap();
1134
1135 mgr.restore(&v1_file, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1137
1138 let current = load_graph(&graph_path).unwrap();
1140 assert_eq!(current.nodes.len(), 1);
1141 assert_eq!(current.nodes[0].id, "a");
1142 assert_eq!(current.nodes[0].title, "Alpha");
1143 }
1144
1145 #[test]
1146 fn test_restore_creates_auto_snapshot() {
1147 let temp = TempDir::new().unwrap();
1148 let gid_dir = temp.path().join(".gid");
1149 fs::create_dir_all(&gid_dir).unwrap();
1150 let mgr = HistoryManager::new(&gid_dir);
1151 let graph_path = gid_dir.join("graph.yml");
1152
1153 let mut v1 = Graph::new();
1155 v1.add_node(Node::new("a", "A"));
1156 let v1_file = mgr.save_snapshot(&v1, Some("v1")).unwrap();
1157
1158 std::thread::sleep(std::time::Duration::from_millis(1100));
1160
1161 let mut v2 = Graph::new();
1163 v2.add_node(Node::new("b", "B"));
1164 v2.add_node(Node::new("c", "C"));
1165 save_graph(&v2, &graph_path).unwrap();
1166
1167 let before_count = mgr.list_snapshots().unwrap().len();
1168
1169 mgr.restore(&v1_file, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1171
1172 let after = mgr.list_snapshots().unwrap();
1173 assert_eq!(after.len(), before_count + 1, "restore should create auto-snapshot");
1174
1175 let auto_snap = after.iter()
1177 .find(|e| e.message.as_deref() == Some("Auto-snapshot before restore"))
1178 .expect("should have auto-snapshot");
1179 assert_eq!(auto_snap.node_count, 2); }
1181
1182 #[test]
1183 fn test_restore_preserves_all_node_data() {
1184 let temp = TempDir::new().unwrap();
1185 let gid_dir = temp.path().join(".gid");
1186 fs::create_dir_all(&gid_dir).unwrap();
1187 let mgr = HistoryManager::new(&gid_dir);
1188 let graph_path = gid_dir.join("graph.yml");
1189
1190 let mut graph = Graph::new();
1191 let mut node = Node::new("task-1", "Complex Task")
1192 .with_status(NodeStatus::Blocked)
1193 .with_description("Blocked on dependencies")
1194 .with_tags(vec!["urgent".to_string(), "backend".to_string()]);
1195 node.assigned_to = Some("potato".to_string());
1196 node.priority = Some(5);
1197 graph.add_node(node);
1198 graph.add_edge(Edge::new("task-1", "task-2", "blocks"));
1199
1200 let filename = mgr.save_snapshot(&graph, Some("original")).unwrap();
1201
1202 save_graph(&Graph::new(), &graph_path).unwrap();
1204
1205 mgr.restore(&filename, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1207
1208 let restored = load_graph(&graph_path).unwrap();
1209 let n = &restored.nodes[0];
1210 assert_eq!(n.id, "task-1");
1211 assert_eq!(n.title, "Complex Task");
1212 assert_eq!(n.status, NodeStatus::Blocked);
1213 assert_eq!(n.description.as_deref(), Some("Blocked on dependencies"));
1214 assert_eq!(n.tags, vec!["urgent", "backend"]);
1215 assert_eq!(n.assigned_to.as_deref(), Some("potato"));
1216 assert_eq!(n.priority, Some(5));
1217 assert_eq!(restored.edges.len(), 1);
1218 assert_eq!(restored.edges[0].relation, "blocks");
1219 }
1220
1221 #[test]
1222 fn test_restore_nonexistent_version_fails() {
1223 let temp = TempDir::new().unwrap();
1224 let gid_dir = temp.path().join(".gid");
1225 fs::create_dir_all(&gid_dir).unwrap();
1226 let mgr = HistoryManager::new(&gid_dir);
1227
1228 let result = mgr.restore("nonexistent.yml", &gid_dir, Some(StorageBackend::Yaml));
1229 assert!(result.is_err());
1230 let err_msg = result.unwrap_err().to_string();
1231 assert!(err_msg.contains("not found"), "Error should mention 'not found': {}", err_msg);
1232 }
1233
1234 #[test]
1235 fn test_load_nonexistent_version_fails() {
1236 let temp = TempDir::new().unwrap();
1237 let gid_dir = temp.path().join(".gid");
1238 fs::create_dir_all(&gid_dir).unwrap();
1239 let mgr = HistoryManager::new(&gid_dir);
1240
1241 let result = mgr.load_version("does-not-exist.yml");
1242 assert!(result.is_err());
1243 }
1244
1245 #[test]
1248 fn test_save_keeps_exactly_max_entries() {
1249 let temp = TempDir::new().unwrap();
1250 let gid_dir = temp.path().join(".gid");
1251 let history_dir = gid_dir.join("history");
1252 fs::create_dir_all(&history_dir).unwrap();
1253 let mgr = HistoryManager::new(&gid_dir);
1254 let graph = Graph::new();
1255
1256 for i in 0..MAX_HISTORY_ENTRIES {
1258 let ts = format!("2024-01-01T00-{:02}-00Z.yml", i);
1259 let path = history_dir.join(&ts);
1260 fs::write(&path, serde_yaml::to_string(&graph).unwrap()).unwrap();
1261 }
1262
1263 let count_before: usize = fs::read_dir(&history_dir).unwrap()
1265 .filter_map(|e| e.ok())
1266 .filter(|e| e.path().extension().map_or(false, |ext| ext == "yml"))
1267 .count();
1268 assert_eq!(count_before, MAX_HISTORY_ENTRIES);
1269
1270 mgr.save_snapshot(&graph, None).unwrap();
1272
1273 let count_after: usize = fs::read_dir(&history_dir).unwrap()
1274 .filter_map(|e| e.ok())
1275 .filter(|e| e.path().extension().map_or(false, |ext| ext == "yml"))
1276 .count();
1277 assert!(count_after <= MAX_HISTORY_ENTRIES);
1278 }
1279
1280 #[test]
1281 fn test_cleanup_removes_oldest_first() {
1282 let temp = TempDir::new().unwrap();
1283 let gid_dir = temp.path().join(".gid");
1284 let history_dir = gid_dir.join("history");
1285 fs::create_dir_all(&history_dir).unwrap();
1286 let mgr = HistoryManager::new(&gid_dir);
1287 let graph = Graph::new();
1288
1289 for i in 0..(MAX_HISTORY_ENTRIES + 2) {
1291 let ts = format!("2024-01-01T00-{:02}-{:02}Z.yml", i / 60, i % 60);
1292 let path = history_dir.join(&ts);
1293 fs::write(&path, serde_yaml::to_string(&graph).unwrap()).unwrap();
1294 }
1295
1296 mgr.save_snapshot(&graph, None).unwrap();
1298
1299 let remaining: Vec<String> = fs::read_dir(&history_dir).unwrap()
1300 .filter_map(|e| e.ok())
1301 .filter(|e| e.path().extension().map_or(false, |ext| ext == "yml"))
1302 .map(|e| e.file_name().to_string_lossy().to_string())
1303 .collect();
1304
1305 assert!(!remaining.contains(&"2024-01-01T00-00-00Z.yml".to_string()),
1307 "Oldest snapshot should be pruned");
1308 }
1309
1310 #[test]
1313 fn test_save_creates_history_directory() {
1314 let temp = TempDir::new().unwrap();
1315 let gid_dir = temp.path().join(".gid");
1316 fs::create_dir_all(&gid_dir).unwrap();
1318 let mgr = HistoryManager::new(&gid_dir);
1319
1320 let graph = Graph::new();
1321 let filename = mgr.save_snapshot(&graph, None).unwrap();
1322 assert!(!filename.is_empty());
1323 assert!(gid_dir.join("history").exists());
1324 }
1325
1326 #[test]
1327 fn test_large_graph_roundtrip() {
1328 let temp = TempDir::new().unwrap();
1329 let gid_dir = temp.path().join(".gid");
1330 fs::create_dir_all(&gid_dir).unwrap();
1331 let mgr = HistoryManager::new(&gid_dir);
1332
1333 let mut graph = Graph::new();
1334 for i in 0..100 {
1336 let node = Node::new(
1337 &format!("node-{:03}", i),
1338 &format!("Node number {}", i),
1339 ).with_status(if i % 3 == 0 { NodeStatus::Done } else { NodeStatus::Todo })
1340 .with_tags(vec![format!("group-{}", i / 10)]);
1341 graph.add_node(node);
1342 if i > 0 {
1343 graph.add_edge(Edge::depends_on(
1344 &format!("node-{:03}", i),
1345 &format!("node-{:03}", i - 1),
1346 ));
1347 }
1348 }
1349
1350 let filename = mgr.save_snapshot(&graph, Some("stress test")).unwrap();
1351 let loaded = mgr.load_version(&filename).unwrap();
1352
1353 assert_eq!(loaded.nodes.len(), 100);
1354 assert_eq!(loaded.edges.len(), 99);
1355
1356 let node_50 = loaded.nodes.iter().find(|n| n.id == "node-050").unwrap();
1358 assert_eq!(node_50.title, "Node number 50");
1359 assert_eq!(node_50.tags, vec!["group-5"]);
1360 }
1361
1362 #[test]
1363 fn test_diff_large_graphs() {
1364 let mut g1 = Graph::new();
1365 let mut g2 = Graph::new();
1366
1367 for i in 0..75 {
1369 g1.add_node(Node::new(&format!("n-{}", i), &format!("Node {}", i)));
1370 }
1371 for i in 0..50 {
1372 if i < 10 {
1373 g2.add_node(Node::new(&format!("n-{}", i), &format!("Modified Node {}", i)));
1375 } else {
1376 g2.add_node(Node::new(&format!("n-{}", i), &format!("Node {}", i)));
1377 }
1378 }
1379 for i in 75..100 {
1380 g2.add_node(Node::new(&format!("n-{}", i), &format!("Node {}", i)));
1381 }
1382
1383 let diff = HistoryManager::diff(&g1, &g2);
1384 assert_eq!(diff.added_nodes.len(), 25); assert_eq!(diff.removed_nodes.len(), 25); assert_eq!(diff.modified_nodes.len(), 10); }
1388
1389 #[test]
1390 fn test_snapshot_with_all_node_statuses() {
1391 let temp = TempDir::new().unwrap();
1392 let gid_dir = temp.path().join(".gid");
1393 fs::create_dir_all(&gid_dir).unwrap();
1394 let mgr = HistoryManager::new(&gid_dir);
1395
1396 let mut graph = Graph::new();
1397 let statuses = vec![
1398 ("s-todo", NodeStatus::Todo),
1399 ("s-progress", NodeStatus::InProgress),
1400 ("s-done", NodeStatus::Done),
1401 ("s-blocked", NodeStatus::Blocked),
1402 ("s-cancelled", NodeStatus::Cancelled),
1403 ("s-failed", NodeStatus::Failed),
1404 ("s-needs-resolution", NodeStatus::NeedsResolution),
1405 ];
1406 for (id, status) in &statuses {
1407 graph.add_node(Node::new(id, &format!("Status: {:?}", status)).with_status(status.clone()));
1408 }
1409
1410 let filename = mgr.save_snapshot(&graph, None).unwrap();
1411 let loaded = mgr.load_version(&filename).unwrap();
1412
1413 assert_eq!(loaded.nodes.len(), 7);
1414 for (id, expected_status) in &statuses {
1415 let node = loaded.nodes.iter().find(|n| n.id == *id).unwrap();
1416 assert_eq!(node.status, *expected_status, "Status mismatch for node {}", id);
1417 }
1418 }
1419
1420 #[test]
1421 fn test_snapshot_with_project_meta() {
1422 let temp = TempDir::new().unwrap();
1423 let gid_dir = temp.path().join(".gid");
1424 fs::create_dir_all(&gid_dir).unwrap();
1425 let mgr = HistoryManager::new(&gid_dir);
1426
1427 let mut graph = Graph::new();
1428 graph.project = Some(ProjectMeta {
1429 name: "test-project".to_string(),
1430 description: Some("A test project with description".to_string()),
1431 });
1432
1433 let filename = mgr.save_snapshot(&graph, None).unwrap();
1434 let loaded = mgr.load_version(&filename).unwrap();
1435
1436 let project = loaded.project.unwrap();
1437 assert_eq!(project.name, "test-project");
1438 assert_eq!(project.description.as_deref(), Some("A test project with description"));
1439 }
1440
1441 #[test]
1442 fn test_snapshot_filename_is_timestamp_yml() {
1443 let temp = TempDir::new().unwrap();
1444 let gid_dir = temp.path().join(".gid");
1445 fs::create_dir_all(&gid_dir).unwrap();
1446 let mgr = HistoryManager::new(&gid_dir);
1447
1448 let graph = Graph::new();
1449 let filename = mgr.save_snapshot(&graph, None).unwrap();
1450
1451 assert!(filename.ends_with("Z.yml"), "Filename should end with Z.yml: {}", filename);
1453 assert!(filename.contains('T'), "Filename should contain T separator: {}", filename);
1454 assert_eq!(filename.len(), 24, "Timestamp filename should be 24 chars: {}", filename);
1455 }
1456
1457 #[test]
1458 fn test_diff_symmetric_property() {
1459 let mut g1 = Graph::new();
1461 g1.add_node(Node::new("a", "A"));
1462 g1.add_node(Node::new("shared", "Shared"));
1463
1464 let mut g2 = Graph::new();
1465 g2.add_node(Node::new("b", "B"));
1466 g2.add_node(Node::new("shared", "Shared"));
1467
1468 let forward = HistoryManager::diff(&g1, &g2);
1469 let backward = HistoryManager::diff(&g2, &g1);
1470
1471 assert_eq!(forward.added_nodes, backward.removed_nodes);
1472 assert_eq!(forward.removed_nodes, backward.added_nodes);
1473 assert_eq!(forward.added_edges, backward.removed_edges);
1474 assert_eq!(forward.removed_edges, backward.added_edges);
1475 }
1476
1477 #[test]
1478 fn test_diff_self_is_empty() {
1479 let mut graph = Graph::new();
1480 graph.add_node(Node::new("a", "A"));
1481 graph.add_edge(Edge::depends_on("a", "b"));
1482
1483 let diff = HistoryManager::diff(&graph, &graph);
1484 assert!(diff.is_empty());
1485 }
1486
1487 #[test]
1488 fn test_save_and_diff_workflow() {
1489 let temp = TempDir::new().unwrap();
1491 let gid_dir = temp.path().join(".gid");
1492 fs::create_dir_all(&gid_dir).unwrap();
1493 let mgr = HistoryManager::new(&gid_dir);
1494
1495 let mut v1 = Graph::new();
1496 v1.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Todo));
1497 v1.add_node(Node::new("b", "Beta"));
1498 v1.add_edge(Edge::depends_on("b", "a"));
1499 let f1 = mgr.save_snapshot(&v1, Some("v1")).unwrap();
1500
1501 std::thread::sleep(std::time::Duration::from_millis(1100));
1502
1503 let mut v2 = Graph::new();
1504 v2.add_node(Node::new("a", "Alpha").with_status(NodeStatus::Done));
1505 v2.add_node(Node::new("c", "Charlie"));
1506 v2.add_edge(Edge::depends_on("c", "a"));
1507 let f2 = mgr.save_snapshot(&v2, Some("v2")).unwrap();
1508
1509 let loaded_v1 = mgr.load_version(&f1).unwrap();
1510 let loaded_v2 = mgr.load_version(&f2).unwrap();
1511 let diff = HistoryManager::diff(&loaded_v1, &loaded_v2);
1512
1513 assert!(diff.added_nodes.contains(&"c".to_string()));
1514 assert!(diff.removed_nodes.contains(&"b".to_string()));
1515 assert!(diff.modified_nodes.contains(&"a".to_string()));
1516 assert_eq!(diff.added_edges, 1); assert_eq!(diff.removed_edges, 1); }
1519
1520 #[test]
1521 fn test_restore_then_diff_shows_empty() {
1522 let temp = TempDir::new().unwrap();
1523 let gid_dir = temp.path().join(".gid");
1524 fs::create_dir_all(&gid_dir).unwrap();
1525 let mgr = HistoryManager::new(&gid_dir);
1526 let graph_path = gid_dir.join("graph.yml");
1527
1528 let mut original = Graph::new();
1529 original.add_node(Node::new("a", "Alpha"));
1530 original.add_node(Node::new("b", "Beta"));
1531 original.add_edge(Edge::depends_on("b", "a"));
1532 let f = mgr.save_snapshot(&original, Some("original")).unwrap();
1533
1534 std::thread::sleep(std::time::Duration::from_millis(1100));
1536
1537 save_graph(&Graph::new(), &graph_path).unwrap();
1539
1540 mgr.restore(&f, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1542 let restored = load_graph(&graph_path).unwrap();
1543
1544 let diff = HistoryManager::diff(&original, &restored);
1547 assert!(diff.is_empty(), "Restored graph should match original: added={:?} removed={:?} modified={:?}",
1548 diff.added_nodes, diff.removed_nodes, diff.modified_nodes);
1549 }
1550
1551 #[test]
1552 fn test_graph_diff_is_empty_helper() {
1553 let diff = GraphDiff {
1554 added_nodes: vec![],
1555 removed_nodes: vec![],
1556 modified_nodes: vec![],
1557 added_edges: 0,
1558 removed_edges: 0,
1559 };
1560 assert!(diff.is_empty());
1561
1562 let diff2 = GraphDiff {
1563 added_nodes: vec!["a".to_string()],
1564 removed_nodes: vec![],
1565 modified_nodes: vec![],
1566 added_edges: 0,
1567 removed_edges: 0,
1568 };
1569 assert!(!diff2.is_empty());
1570 }
1571
1572 #[test]
1573 fn test_diff_only_edges_changed() {
1574 let mut g1 = Graph::new();
1575 g1.add_node(Node::new("a", "A"));
1576 g1.add_node(Node::new("b", "B"));
1577 g1.add_edge(Edge::depends_on("b", "a"));
1578
1579 let mut g2 = Graph::new();
1580 g2.add_node(Node::new("a", "A"));
1581 g2.add_node(Node::new("b", "B"));
1582 g2.add_edge(Edge::new("a", "b", "blocks"));
1583
1584 let diff = HistoryManager::diff(&g1, &g2);
1585 assert!(diff.added_nodes.is_empty());
1586 assert!(diff.removed_nodes.is_empty());
1587 assert!(diff.modified_nodes.is_empty());
1588 assert_eq!(diff.added_edges, 1);
1589 assert_eq!(diff.removed_edges, 1);
1590 }
1591
1592 #[test]
1593 fn test_multiple_edges_between_same_nodes() {
1594 let mut g1 = Graph::new();
1595 g1.add_node(Node::new("a", "A"));
1596 g1.add_node(Node::new("b", "B"));
1597 g1.add_edge(Edge::new("a", "b", "depends_on"));
1598 g1.add_edge(Edge::new("a", "b", "relates_to"));
1599
1600 let mut g2 = Graph::new();
1601 g2.add_node(Node::new("a", "A"));
1602 g2.add_node(Node::new("b", "B"));
1603 g2.add_edge(Edge::new("a", "b", "depends_on"));
1604 g2.add_edge(Edge::new("a", "b", "blocks"));
1605
1606 let diff = HistoryManager::diff(&g1, &g2);
1607 assert_eq!(diff.added_edges, 1);
1609 assert_eq!(diff.removed_edges, 1);
1610 }
1611
1612 #[test]
1613 fn test_snapshot_with_special_chars_in_message() {
1614 let temp = TempDir::new().unwrap();
1615 let gid_dir = temp.path().join(".gid");
1616 fs::create_dir_all(&gid_dir).unwrap();
1617 let mgr = HistoryManager::new(&gid_dir);
1618
1619 let graph = Graph::new();
1620 let _filename = mgr.save_snapshot(&graph, Some("fix: issue #42 — 'quoted' & \"double\"")).unwrap();
1622
1623 let entries = mgr.list_snapshots().unwrap();
1624 assert_eq!(entries.len(), 1);
1625 assert!(entries[0].message.is_some());
1627 }
1628
1629 #[test]
1630 fn test_restore_without_existing_graph_file() {
1631 let temp = TempDir::new().unwrap();
1632 let gid_dir = temp.path().join(".gid");
1633 fs::create_dir_all(&gid_dir).unwrap();
1634 let mgr = HistoryManager::new(&gid_dir);
1635 let graph_path = gid_dir.join("graph.yml");
1636
1637 let mut graph = Graph::new();
1638 graph.add_node(Node::new("a", "A"));
1639 let filename = mgr.save_snapshot(&graph, None).unwrap();
1640
1641 assert!(!graph_path.exists());
1643 mgr.restore(&filename, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1644
1645 let restored = load_graph(&graph_path).unwrap();
1646 assert_eq!(restored.nodes.len(), 1);
1647 assert_eq!(restored.nodes[0].id, "a");
1648 }
1649
1650 #[test]
1651 fn test_diff_against_nonexistent_version() {
1652 let temp = TempDir::new().unwrap();
1653 let gid_dir = temp.path().join(".gid");
1654 fs::create_dir_all(&gid_dir).unwrap();
1655 let mgr = HistoryManager::new(&gid_dir);
1656
1657 let graph = Graph::new();
1658 let result = mgr.diff_against("fake.yml", &graph);
1659 assert!(result.is_err());
1660 }
1661
1662 #[test]
1663 fn test_history_entry_struct_fields() {
1664 let entry = HistoryEntry {
1665 filename: "2026-04-08T01-00-00Z.yml".to_string(),
1666 timestamp: "2026:04:08 01:00:00".to_string(),
1667 message: Some("test message".to_string()),
1668 node_count: 5,
1669 edge_count: 3,
1670 git_commit: Some("abc123".to_string()),
1671 };
1672 assert_eq!(entry.filename, "2026-04-08T01-00-00Z.yml");
1673 assert_eq!(entry.node_count, 5);
1674 assert_eq!(entry.edge_count, 3);
1675 assert_eq!(entry.git_commit.as_deref(), Some("abc123"));
1676 }
1677
1678 #[test]
1679 fn test_snapshot_with_knowledge_node() {
1680 use crate::task_graph_knowledge::{KnowledgeNode, ToolCallRecord};
1681
1682 let temp = TempDir::new().unwrap();
1683 let gid_dir = temp.path().join(".gid");
1684 fs::create_dir_all(&gid_dir).unwrap();
1685 let mgr = HistoryManager::new(&gid_dir);
1686
1687 let mut graph = Graph::new();
1688 let mut node = Node::new("k-1", "Knowledge test");
1689 node.knowledge = KnowledgeNode {
1690 findings: std::collections::HashMap::from([
1691 ("f1".to_string(), "Finding 1".to_string()),
1692 ("f2".to_string(), "Finding 2".to_string()),
1693 ]),
1694 file_cache: std::collections::HashMap::from([
1695 ("src/main.rs".to_string(), "fn main() {}".to_string()),
1696 ]),
1697 tool_history: vec![ToolCallRecord {
1698 tool_name: "read_file".to_string(),
1699 timestamp: "2026-04-08T00:00:00Z".to_string(),
1700 summary: "Read src/main.rs".to_string(),
1701 }],
1702 };
1703 graph.add_node(node);
1704
1705 let filename = mgr.save_snapshot(&graph, None).unwrap();
1706 let loaded = mgr.load_version(&filename).unwrap();
1707
1708 let n = &loaded.nodes[0];
1709 assert_eq!(n.knowledge.findings.len(), 2);
1710 assert_eq!(n.knowledge.findings.get("f1").unwrap(), "Finding 1");
1711 assert_eq!(n.knowledge.file_cache.get("src/main.rs").unwrap(), "fn main() {}");
1712 assert_eq!(n.knowledge.tool_history.len(), 1);
1713 assert_eq!(n.knowledge.tool_history[0].tool_name, "read_file");
1714 }
1715
1716 #[test]
1719 fn test_diff_versions_basic() {
1720 let temp = TempDir::new().unwrap();
1721 let gid_dir = temp.path().join(".gid");
1722 fs::create_dir_all(&gid_dir).unwrap();
1723 let mgr = HistoryManager::new(&gid_dir);
1724
1725 let mut graph_a = Graph::new();
1727 graph_a.add_node(Node::new("a", "Alpha"));
1728 let file_a = mgr.save_snapshot(&graph_a, Some("v1")).unwrap();
1729
1730 std::thread::sleep(std::time::Duration::from_millis(1100));
1732
1733 let mut graph_b = Graph::new();
1735 graph_b.add_node(Node::new("a", "Alpha Changed"));
1736 graph_b.add_node(Node::new("b", "Beta"));
1737 let file_b = mgr.save_snapshot(&graph_b, Some("v2")).unwrap();
1738
1739 let diff = mgr.diff_versions(&file_a, &file_b).unwrap();
1740 assert_eq!(diff.added_nodes, vec!["b"]);
1741 assert!(diff.removed_nodes.is_empty());
1742 assert_eq!(diff.modified_nodes, vec!["a"]);
1743 }
1744
1745 #[test]
1746 fn test_diff_versions_same() {
1747 let temp = TempDir::new().unwrap();
1748 let gid_dir = temp.path().join(".gid");
1749 fs::create_dir_all(&gid_dir).unwrap();
1750 let mgr = HistoryManager::new(&gid_dir);
1751
1752 let mut graph = Graph::new();
1753 graph.add_node(Node::new("a", "Alpha"));
1754 let filename = mgr.save_snapshot(&graph, Some("v1")).unwrap();
1755
1756 let diff = mgr.diff_versions(&filename, &filename).unwrap();
1758 assert!(diff.is_empty());
1759 }
1760
1761 #[test]
1762 fn test_diff_versions_nonexistent() {
1763 let temp = TempDir::new().unwrap();
1764 let gid_dir = temp.path().join(".gid");
1765 fs::create_dir_all(&gid_dir).unwrap();
1766 let mgr = HistoryManager::new(&gid_dir);
1767
1768 let mut graph = Graph::new();
1769 graph.add_node(Node::new("a", "Alpha"));
1770 let filename = mgr.save_snapshot(&graph, Some("v1")).unwrap();
1771
1772 let result = mgr.diff_versions("nonexistent.yml", &filename);
1774 assert!(result.is_err());
1775
1776 let result = mgr.diff_versions(&filename, "nonexistent.yml");
1778 assert!(result.is_err());
1779
1780 let result = mgr.diff_versions("nope1.yml", "nope2.yml");
1782 assert!(result.is_err());
1783 }
1784
1785 #[cfg(feature = "sqlite")]
1786 mod sqlite_backup_tests {
1787 use super::*;
1788 use rusqlite::Connection;
1789
1790 fn create_test_db(path: &Path) -> Connection {
1791 let conn = Connection::open(path).unwrap();
1792 conn.execute_batch("
1793 CREATE TABLE nodes (id TEXT PRIMARY KEY, title TEXT, status TEXT);
1794 CREATE TABLE edges (from_id TEXT, to_id TEXT, relation TEXT);
1795 INSERT INTO nodes VALUES ('task-1', 'Auth', 'todo');
1796 INSERT INTO nodes VALUES ('task-2', 'Dashboard', 'done');
1797 INSERT INTO edges VALUES ('task-2', 'task-1', 'depends_on');
1798 ").unwrap();
1799 conn
1800 }
1801
1802 #[test]
1803 fn test_sqlite_snapshot_save() {
1804 let tmp = tempfile::tempdir().unwrap();
1805 let gid_dir = tmp.path().join(".gid");
1806 fs::create_dir_all(&gid_dir).unwrap();
1807 let mgr = HistoryManager::new(&gid_dir);
1808
1809 let db_path = gid_dir.join("graph.db");
1810 let conn = create_test_db(&db_path);
1811
1812 let filename = mgr.save_snapshot_sqlite(&conn, Some("test snapshot")).unwrap();
1813 assert!(filename.ends_with(".db"));
1814
1815 let snap_path = gid_dir.join("history").join(&filename);
1817 assert!(snap_path.exists());
1818
1819 let snap_conn = Connection::open(&snap_path).unwrap();
1820 let count: i64 = snap_conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0)).unwrap();
1821 assert_eq!(count, 2);
1822
1823 let edge_count: i64 = snap_conn.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0)).unwrap();
1824 assert_eq!(edge_count, 1);
1825 }
1826
1827 #[test]
1828 fn test_sqlite_snapshot_integrity_verified() {
1829 let tmp = tempfile::tempdir().unwrap();
1830 let gid_dir = tmp.path().join(".gid");
1831 fs::create_dir_all(&gid_dir).unwrap();
1832 let mgr = HistoryManager::new(&gid_dir);
1833
1834 let db_path = gid_dir.join("graph.db");
1835 let conn = create_test_db(&db_path);
1836
1837 let filename = mgr.save_snapshot_sqlite(&conn, None).unwrap();
1839 assert!(filename.ends_with(".db"));
1840 }
1841
1842 #[test]
1843 fn test_sqlite_snapshot_meta_file() {
1844 let tmp = tempfile::tempdir().unwrap();
1845 let gid_dir = tmp.path().join(".gid");
1846 fs::create_dir_all(&gid_dir).unwrap();
1847 let mgr = HistoryManager::new(&gid_dir);
1848
1849 let db_path = gid_dir.join("graph.db");
1850 let conn = create_test_db(&db_path);
1851
1852 let filename = mgr.save_snapshot_sqlite(&conn, Some("v1 release")).unwrap();
1853
1854 let meta_path = gid_dir.join("history").join(format!("{}.meta", filename));
1856 assert!(meta_path.exists());
1857
1858 let meta: serde_json::Value = serde_json::from_str(&fs::read_to_string(&meta_path).unwrap()).unwrap();
1859 assert_eq!(meta["message"], "v1 release");
1860 assert!(meta["checksum"].as_str().unwrap().starts_with("sha256:"));
1861 }
1862
1863 #[test]
1864 fn test_sqlite_snapshot_no_meta_without_message() {
1865 let tmp = tempfile::tempdir().unwrap();
1866 let gid_dir = tmp.path().join(".gid");
1867 fs::create_dir_all(&gid_dir).unwrap();
1868 let mgr = HistoryManager::new(&gid_dir);
1869
1870 let db_path = gid_dir.join("graph.db");
1871 let conn = create_test_db(&db_path);
1872
1873 let filename = mgr.save_snapshot_sqlite(&conn, None).unwrap();
1874
1875 let meta_path = gid_dir.join("history").join(format!("{}.meta", filename));
1877 assert!(!meta_path.exists());
1878 }
1879
1880 #[test]
1881 fn test_sqlite_snapshot_collision_handling() {
1882 let tmp = tempfile::tempdir().unwrap();
1883 let gid_dir = tmp.path().join(".gid");
1884 fs::create_dir_all(&gid_dir).unwrap();
1885 let mgr = HistoryManager::new(&gid_dir);
1886
1887 let db_path = gid_dir.join("graph.db");
1888 let conn = create_test_db(&db_path);
1889
1890 let f1 = mgr.save_snapshot_sqlite(&conn, Some("first")).unwrap();
1892 let f2 = mgr.save_snapshot_sqlite(&conn, Some("second")).unwrap();
1893 assert_ne!(f1, f2);
1894
1895 let snap1 = Connection::open(gid_dir.join("history").join(&f1)).unwrap();
1897 let snap2 = Connection::open(gid_dir.join("history").join(&f2)).unwrap();
1898 let c1: i64 = snap1.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0)).unwrap();
1899 let c2: i64 = snap2.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0)).unwrap();
1900 assert_eq!(c1, 2);
1901 assert_eq!(c2, 2);
1902 }
1903 }
1904}