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
5// GOAL-3.1: SQLite backend snapshots use `save_snapshot_sqlite()` with rusqlite::backup::Backup
6// for atomic, consistent .db snapshots. YAML backend uses `save_snapshot()` with serde_yaml.
7// The rusqlite "backup" feature is enabled in Cargo.toml.
8
9use 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;  // for load_version() — history snapshots are always YAML
16use crate::storage::{load_graph_auto, save_graph_auto, StorageBackend};  // for restore()
17
18#[cfg(feature = "sqlite")]
19use sha2::{Sha256, Digest};
20
21/// Maximum number of history entries to keep.
22const MAX_HISTORY_ENTRIES: usize = 50;
23
24/// A history snapshot entry.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct HistoryEntry {
27    /// Filename of the snapshot (e.g., "2024-03-25T12-30-00Z.yml")
28    pub filename: String,
29    /// ISO 8601 timestamp
30    pub timestamp: String,
31    /// Optional commit-like message
32    pub message: Option<String>,
33    /// Number of nodes in this snapshot
34    pub node_count: usize,
35    /// Number of edges in this snapshot
36    pub edge_count: usize,
37    /// Git commit hash if available
38    pub git_commit: Option<String>,
39}
40
41/// Diff result between two graph versions.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GraphDiff {
44    /// Nodes added in the newer version
45    pub added_nodes: Vec<String>,
46    /// Nodes removed from the older version
47    pub removed_nodes: Vec<String>,
48    /// Nodes that changed (status, title, etc.)
49    pub modified_nodes: Vec<String>,
50    /// Number of edges added
51    pub added_edges: usize,
52    /// Number of edges removed
53    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
118/// History manager for a GID project.
119pub struct HistoryManager {
120    history_dir: PathBuf,
121}
122
123impl HistoryManager {
124    /// Create a new history manager for the given .gid directory.
125    pub fn new(gid_dir: &Path) -> Self {
126        Self {
127            history_dir: gid_dir.join("history"),
128        }
129    }
130    
131    /// Ensure the history directory exists.
132    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    /// Save a snapshot of the current graph.
141    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        // Add message as a comment at the top if provided
150        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        // Clean up old history entries
161        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    /// Save a snapshot of the current SQLite graph database using the Backup API.
175    ///
176    /// Uses `rusqlite::backup::Backup` for atomic, consistent point-in-time snapshots
177    /// even with concurrent readers and WAL mode enabled.
178    ///
179    /// Returns the snapshot filename (e.g., "2026-04-09T13-45-00Z.db").
180    ///
181    /// [GOAL 3.1]
182    #[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        // Generate unique filename, handle same-second collisions
193        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        // Perform backup via SQLite Backup API
212        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        // Verify snapshot integrity
224        {
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        // Compute SHA-256 checksum
235        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        // Store message as a sidecar .meta file for this snapshot
253        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        // Clean up old history entries
265        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    /// List all history snapshots.
280    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        // Sort by filename (which includes timestamp) in descending order
295        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            // Extract timestamp from filename
302            let timestamp = filename
303                .trim_end_matches(".yml")
304                .trim_end_matches(".yaml")
305                .replace('T', " ")
306                .replace('-', ":");
307            
308            // Try to load the graph to get stats
309            if let Ok(content) = fs::read_to_string(&filepath) {
310                // Extract message from first line if it's a comment
311                let message = content.lines().next()
312                    .filter(|l| l.starts_with("# "))
313                    .map(|l| l[2..].to_string());
314                
315                // Parse the graph
316                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, // TODO: Extract from metadata
324                    });
325                }
326            }
327        }
328        
329        Ok(entries)
330    }
331    
332    /// Load a historical version by filename.
333    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    /// Compute diff between two graphs.
344    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        // Find modified nodes (same ID but different content)
359        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        // Edge comparison
374        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    /// Diff current graph against a historical version.
394    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    /// Diff two historical snapshots against each other.
413    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    /// Restore a historical version to the main graph file.
434    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        // Save current state to history first
439        if let Ok(current) = load_graph_auto(gid_dir, backend) {
440            if !current.nodes.is_empty() || !current.edges.is_empty() {
441                self.save_snapshot(&current, Some("Auto-snapshot before restore"))?;
442            }
443        }
444        
445        // Write the historical version as the current graph
446        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    /// Clean up old history entries, keeping only the most recent N.
459    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        // Sort by filename in ascending order (oldest first)
468        files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
469        
470        // Remove oldest files if we have too many
471        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        // Create MAX + 5 snapshots via save_snapshot
536        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        // Now save one more — this should trigger cleanup
544        mgr.save_snapshot(&graph, Some("trigger prune")).unwrap();
545
546        // Count remaining files
547        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    // ── Roundtrip Tests ──
562
563    #[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        // Verify project meta
615        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        // Verify node
620        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        // Verify edge
651        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        // Snapshot 1: empty
708        let mut graph = Graph::new();
709        let f1 = mgr.save_snapshot(&graph, Some("v1: empty")).unwrap();
710
711        // Snapshot 2: one node
712        graph.add_node(Node::new("a", "Alpha"));
713        // sleep a tiny bit to guarantee different timestamps
714        std::thread::sleep(std::time::Duration::from_millis(1100));
715        let f2 = mgr.save_snapshot(&graph, Some("v2: one node")).unwrap();
716
717        // Snapshot 3: two nodes + edge
718        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        // All three are distinct files
724        assert_ne!(f1, f2);
725        assert_ne!(f2, f3);
726
727        // Load each and verify
728        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    // ── List Tests ──
790
791    #[test]
792    fn test_list_empty_directory() {
793        let temp = TempDir::new().unwrap();
794        let gid_dir = temp.path().join(".gid");
795        // Don't even create the history dir
796        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        // Create files with known timestamps (sorted alphabetically = chronologically)
811        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        // list_snapshots sorts descending (newest first)
824        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        // Create MAX + 3 snapshot files directly on disk
861        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        // list_snapshots should NOT prune
869        let entries = mgr.list_snapshots().unwrap();
870        assert_eq!(entries.len(), total);
871
872        // Verify files still on disk (no pruning happened)
873        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    // ── Diff Tests ──
882
883    #[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        // Edge identity is (from, to, relation), so changing relation = remove old + add new
995        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")); // will be removed
1005        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)); // modified
1009        g2.add_node(Node::new("b", "Beta")); // unchanged
1010        g2.add_node(Node::new("d", "Delta")); // added
1011        g2.add_edge(Edge::depends_on("d", "a")); // new edge, old edge removed
1012
1013        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, &current).unwrap();
1111        assert_eq!(diff.added_nodes, vec!["b"]);
1112        assert!(diff.removed_nodes.is_empty());
1113    }
1114
1115    // ── Restore Tests ──
1116
1117    #[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        // Save v1 with node A
1126        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        // Write v2 as current (node B only)
1131        let mut v2 = Graph::new();
1132        v2.add_node(Node::new("b", "Beta"));
1133        save_graph(&v2, &graph_path).unwrap();
1134
1135        // Restore v1
1136        mgr.restore(&v1_file, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1137
1138        // Current graph should now be v1
1139        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        // Save v1
1154        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        // Ensure different timestamp for auto-snapshot
1159        std::thread::sleep(std::time::Duration::from_millis(1100));
1160
1161        // Write v2 as current
1162        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        // Restore v1 — should auto-snapshot v2 first
1170        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        // The auto-snapshot should contain v2's data
1176        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); // v2 had nodes b + c
1180    }
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        // Write something else as current
1203        save_graph(&Graph::new(), &graph_path).unwrap();
1204
1205        // Restore
1206        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    // ── Pruning / Cleanup Tests ──
1246
1247    #[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        // Pre-populate exactly MAX entries
1257        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        // Count before
1264        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        // Save one more — should prune oldest
1271        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        // Create MAX + 2 with known timestamps
1290        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        // Save one more to trigger cleanup
1297        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        // The very first (oldest) file should have been pruned
1306        assert!(!remaining.contains(&"2024-01-01T00-00-00Z.yml".to_string()),
1307            "Oldest snapshot should be pruned");
1308    }
1309
1310    // ── Edge Case Tests ──
1311
1312    #[test]
1313    fn test_save_creates_history_directory() {
1314        let temp = TempDir::new().unwrap();
1315        let gid_dir = temp.path().join(".gid");
1316        // Note: NOT creating .gid/history/ manually
1317        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        // 100 nodes, 99 edges (chain)
1335        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        // Spot check some nodes
1357        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        // Both share 50 nodes, g1 has 25 extra, g2 has 25 extra, 10 are modified
1368        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                // Modified: different title
1374                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); // n-75 through n-99
1385        assert_eq!(diff.removed_nodes.len(), 25); // n-50 through n-74
1386        assert_eq!(diff.modified_nodes.len(), 10); // n-0 through n-9
1387    }
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        // Should match pattern: YYYY-MM-DDTHH-MM-SSZ.yml
1452        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        // diff(A, B) added == diff(B, A) removed, and vice versa
1460        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        // End-to-end: save two versions, diff them
1490        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); // c→a
1517        assert_eq!(diff.removed_edges, 1); // b→a
1518    }
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        // Ensure different timestamp for the auto-snapshot in restore
1535        std::thread::sleep(std::time::Duration::from_millis(1100));
1536
1537        // Write different current graph
1538        save_graph(&Graph::new(), &graph_path).unwrap();
1539
1540        // Restore and verify
1541        mgr.restore(&f, &gid_dir, Some(StorageBackend::Yaml)).unwrap();
1542        let restored = load_graph(&graph_path).unwrap();
1543
1544        // Compare against original directly (not re-loading snapshot, which may have been
1545        // overwritten by the auto-snapshot if timestamps collide)
1546        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        // relates_to removed, blocks added
1608        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        // Message with YAML-special characters
1621        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        // Message is stored as YAML comment, so it should survive read
1626        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        // graph_path doesn't exist — restore should still work
1642        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    // ── diff_versions Tests ──
1717
1718    #[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        // Snapshot A: one node
1726        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        // Sleep to ensure different filenames
1731        std::thread::sleep(std::time::Duration::from_millis(1100));
1732
1733        // Snapshot B: different node
1734        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        // Diff a version against itself should show no changes
1757        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        // Nonexistent version_a
1773        let result = mgr.diff_versions("nonexistent.yml", &filename);
1774        assert!(result.is_err());
1775
1776        // Nonexistent version_b
1777        let result = mgr.diff_versions(&filename, "nonexistent.yml");
1778        assert!(result.is_err());
1779
1780        // Both nonexistent
1781        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            // Verify the snapshot file exists and is valid SQLite
1816            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            // Should succeed — integrity check passes on valid DB
1838            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            // Check meta file exists
1855            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            // No meta file when message is None
1876            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            // Save two snapshots rapidly — should get different filenames
1891            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            // Both should be valid
1896            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}