Skip to main content

gid_core/storage/
mod.rs

1pub mod error;
2pub mod trait_def;
3pub mod schema;
4
5#[cfg(feature = "sqlite")]
6pub mod sqlite;
7
8#[cfg(feature = "sqlite")]
9pub mod migration;
10
11#[cfg(test)]
12#[cfg(feature = "sqlite")]
13mod integration_tests;
14
15// Re-export key types for convenience.
16pub use error::{StorageError, StorageOp, StorageResult};
17pub use trait_def::{BatchOp, GraphStorage, NodeFilter};
18pub use schema::SCHEMA_SQL;
19
20#[cfg(feature = "sqlite")]
21pub use sqlite::{Direction, SqliteStorage};
22
23#[cfg(feature = "sqlite")]
24pub use migration::{migrate, MigrationConfig, MigrationReport, MigrationError, MigrationStatus, ValidationLevel};
25
26// =============================================================================
27// Backend Auto-Detection
28// =============================================================================
29
30/// Detected storage backend for a project.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum StorageBackend {
33    /// YAML file-based storage (graph.yml).
34    Yaml,
35    /// SQLite database storage (graph.db).
36    Sqlite,
37}
38
39impl std::fmt::Display for StorageBackend {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            StorageBackend::Yaml => write!(f, "yaml"),
43            StorageBackend::Sqlite => write!(f, "sqlite"),
44        }
45    }
46}
47
48/// Auto-detect storage backend for a `.gid/` directory.
49///
50/// Detection rules:
51/// 1. If `graph.db` exists → SQLite
52/// 2. If `graph.yml` exists → YAML
53/// 3. Neither → default to SQLite (new project)
54///
55/// The user can override via `--backend yaml|sqlite` CLI flag.
56pub fn detect_backend(gid_dir: &std::path::Path) -> StorageBackend {
57    let db_path = gid_dir.join("graph.db");
58    let yaml_path = gid_dir.join("graph.yml");
59
60    if db_path.exists() {
61        StorageBackend::Sqlite
62    } else if yaml_path.exists() {
63        StorageBackend::Yaml
64    } else {
65        // New project — default to SQLite
66        StorageBackend::Sqlite
67    }
68}
69
70/// Resolve backend from explicit flag or auto-detection.
71///
72/// If `explicit` is Some, use it. Otherwise, auto-detect from `gid_dir`.
73pub fn resolve_backend(
74    explicit: Option<StorageBackend>,
75    gid_dir: &std::path::Path,
76) -> StorageBackend {
77    explicit.unwrap_or_else(|| detect_backend(gid_dir))
78}
79
80impl std::str::FromStr for StorageBackend {
81    type Err = String;
82
83    fn from_str(s: &str) -> Result<Self, Self::Err> {
84        match s.to_lowercase().as_str() {
85            "yaml" | "yml" => Ok(StorageBackend::Yaml),
86            "sqlite" | "db" | "sql" => Ok(StorageBackend::Sqlite),
87            _ => Err(format!("unknown backend '{}': expected 'yaml' or 'sqlite'", s)),
88        }
89    }
90}
91
92// =============================================================================
93// Graph ↔ SQLite Bridge
94// =============================================================================
95
96/// Load a complete `Graph` from a SQLite database.
97///
98/// Reads all nodes (with tags, metadata, knowledge) and all edges,
99/// plus project metadata, and assembles them into a `Graph` struct.
100#[cfg(feature = "sqlite")]
101pub fn load_graph_from_sqlite(db_path: &std::path::Path) -> Result<crate::Graph, StorageError> {
102    let storage = SqliteStorage::open(db_path)?;
103
104    // Load project metadata
105    let project = storage.get_project_meta()?;
106
107    // Load all nodes
108    let ids = storage.get_all_node_ids()?;
109    let mut nodes = Vec::with_capacity(ids.len());
110    for id in &ids {
111        if let Some(node) = storage.get_node(id)? {
112            nodes.push(node);
113        }
114    }
115
116    // Load all edges (deduplicated via HashSet since get_edges returns both directions)
117    let mut seen_edges = std::collections::HashSet::new();
118    let mut edges = Vec::new();
119    for id in &ids {
120        for edge in storage.get_edges(id)? {
121            let key = (edge.from.clone(), edge.to.clone(), edge.relation.clone());
122            if seen_edges.insert(key) {
123                edges.push(edge);
124            }
125        }
126    }
127
128    Ok(crate::Graph {
129        project,
130        nodes,
131        edges,
132    })
133}
134
135/// Save a complete `Graph` to a SQLite database.
136///
137/// Opens (or creates) the database, clears existing data, then inserts
138/// all nodes and edges from the Graph. Uses batch operations for atomicity.
139///
140/// Note: `put_node_on()` already syncs tags, metadata, and knowledge,
141/// so we only need `PutNode` + `AddEdge` operations.
142#[cfg(feature = "sqlite")]
143pub fn save_graph_to_sqlite(graph: &crate::Graph, db_path: &std::path::Path) -> Result<(), StorageError> {
144    let storage = SqliteStorage::open(db_path)?;
145
146    // Build batch operations: delete all existing, then insert all new
147    let mut ops = Vec::new();
148
149    // Delete existing nodes (cascades to edges, tags, metadata, knowledge)
150    let existing_ids = storage.get_all_node_ids()?;
151    for id in existing_ids {
152        ops.push(BatchOp::DeleteNode(id));
153    }
154
155    // Insert all nodes (put_node_on handles tags, metadata, knowledge internally)
156    for node in &graph.nodes {
157        ops.push(BatchOp::PutNode(node.clone()));
158    }
159
160    // Insert all edges
161    for edge in &graph.edges {
162        ops.push(BatchOp::AddEdge(edge.clone()));
163    }
164
165    // Use migration batch (FK-disabled) to handle edge ordering
166    storage.execute_migration_batch(&ops)?;
167
168    // Set project metadata
169    if let Some(ref meta) = graph.project {
170        storage.set_project_meta(meta)?;
171    }
172
173    Ok(())
174}
175
176// =============================================================================
177// Auto-Loading/Saving (Backend-Agnostic)
178// =============================================================================
179
180/// Load a `Graph` from the appropriate backend, auto-detected from the `.gid/` directory.
181///
182/// - If `graph.db` exists → load from SQLite
183/// - If `graph.yml` exists → load from YAML
184/// - Neither → empty Graph (YAML default)
185///
186/// The `gid_dir` should point to the `.gid/` directory.
187/// `explicit_backend` allows CLI override via `--backend`.
188pub fn load_graph_auto(
189    gid_dir: &std::path::Path,
190    explicit_backend: Option<StorageBackend>,
191) -> Result<crate::Graph, Box<dyn std::error::Error + Send + Sync>> {
192    let backend = resolve_backend(explicit_backend, gid_dir);
193    match backend {
194        StorageBackend::Yaml => {
195            let yaml_path = gid_dir.join("graph.yml");
196            if yaml_path.exists() {
197                crate::load_graph(&yaml_path).map_err(|e| e.into())
198            } else {
199                Ok(crate::Graph::default())
200            }
201        }
202        #[cfg(feature = "sqlite")]
203        StorageBackend::Sqlite => {
204            let db_path = gid_dir.join("graph.db");
205            if db_path.exists() {
206                load_graph_from_sqlite(&db_path).map_err(|e| e.into())
207            } else {
208                Ok(crate::Graph::default())
209            }
210        }
211        #[cfg(not(feature = "sqlite"))]
212        StorageBackend::Sqlite => {
213            Err("SQLite backend not available (compile with --features sqlite)".into())
214        }
215    }
216}
217
218/// Save a `Graph` to the appropriate backend, auto-detected from the `.gid/` directory.
219///
220/// The `gid_dir` should point to the `.gid/` directory.
221/// `explicit_backend` allows CLI override via `--backend`.
222pub fn save_graph_auto(
223    graph: &crate::Graph,
224    gid_dir: &std::path::Path,
225    explicit_backend: Option<StorageBackend>,
226) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
227    let backend = resolve_backend(explicit_backend, gid_dir);
228    match backend {
229        StorageBackend::Yaml => {
230            let yaml_path = gid_dir.join("graph.yml");
231            crate::save_graph(graph, &yaml_path).map_err(|e| e.into())
232        }
233        #[cfg(feature = "sqlite")]
234        StorageBackend::Sqlite => {
235            let db_path = gid_dir.join("graph.db");
236            save_graph_to_sqlite(graph, &db_path).map_err(|e| e.into())
237        }
238        #[cfg(not(feature = "sqlite"))]
239        StorageBackend::Sqlite => {
240            Err("SQLite backend not available (compile with --features sqlite)".into())
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use tempfile::TempDir;
249    use std::fs;
250
251    #[test]
252    fn test_detect_yaml_when_graph_yml_exists() {
253        let tmp = TempDir::new().unwrap();
254        fs::write(tmp.path().join("graph.yml"), "nodes: []\nedges: []\n").unwrap();
255        assert_eq!(detect_backend(tmp.path()), StorageBackend::Yaml);
256    }
257
258    #[test]
259    fn test_detect_sqlite_when_graph_db_exists() {
260        let tmp = TempDir::new().unwrap();
261        fs::write(tmp.path().join("graph.db"), "").unwrap(); // empty file simulates DB
262        assert_eq!(detect_backend(tmp.path()), StorageBackend::Sqlite);
263    }
264
265    #[test]
266    fn test_detect_sqlite_takes_priority_over_yaml() {
267        let tmp = TempDir::new().unwrap();
268        fs::write(tmp.path().join("graph.yml"), "nodes: []\n").unwrap();
269        fs::write(tmp.path().join("graph.db"), "").unwrap();
270        // Both exist — SQLite takes priority (migrated project)
271        assert_eq!(detect_backend(tmp.path()), StorageBackend::Sqlite);
272    }
273
274    #[test]
275    fn test_detect_defaults_to_sqlite_empty_dir() {
276        let tmp = TempDir::new().unwrap();
277        assert_eq!(detect_backend(tmp.path()), StorageBackend::Sqlite);
278    }
279
280    #[test]
281    fn test_resolve_explicit_overrides_detection() {
282        let tmp = TempDir::new().unwrap();
283        fs::write(tmp.path().join("graph.db"), "").unwrap();
284        // DB exists → would auto-detect SQLite, but explicit says YAML
285        assert_eq!(
286            resolve_backend(Some(StorageBackend::Yaml), tmp.path()),
287            StorageBackend::Yaml
288        );
289    }
290
291    #[test]
292    fn test_resolve_none_delegates_to_detection() {
293        let tmp = TempDir::new().unwrap();
294        fs::write(tmp.path().join("graph.yml"), "").unwrap();
295        assert_eq!(
296            resolve_backend(None, tmp.path()),
297            StorageBackend::Yaml
298        );
299    }
300
301    #[test]
302    fn test_backend_display() {
303        assert_eq!(StorageBackend::Yaml.to_string(), "yaml");
304        assert_eq!(StorageBackend::Sqlite.to_string(), "sqlite");
305    }
306
307    #[test]
308    fn test_backend_equality() {
309        assert_eq!(StorageBackend::Yaml, StorageBackend::Yaml);
310        assert_eq!(StorageBackend::Sqlite, StorageBackend::Sqlite);
311        assert_ne!(StorageBackend::Yaml, StorageBackend::Sqlite);
312    }
313
314    #[test]
315    fn test_backend_from_str() {
316        assert_eq!("yaml".parse::<StorageBackend>().unwrap(), StorageBackend::Yaml);
317        assert_eq!("yml".parse::<StorageBackend>().unwrap(), StorageBackend::Yaml);
318        assert_eq!("sqlite".parse::<StorageBackend>().unwrap(), StorageBackend::Sqlite);
319        assert_eq!("db".parse::<StorageBackend>().unwrap(), StorageBackend::Sqlite);
320        assert_eq!("sql".parse::<StorageBackend>().unwrap(), StorageBackend::Sqlite);
321        assert!("unknown".parse::<StorageBackend>().is_err());
322    }
323
324    #[test]
325    fn test_backend_from_str_case_insensitive() {
326        assert_eq!("YAML".parse::<StorageBackend>().unwrap(), StorageBackend::Yaml);
327        assert_eq!("SQLite".parse::<StorageBackend>().unwrap(), StorageBackend::Sqlite);
328        assert_eq!("DB".parse::<StorageBackend>().unwrap(), StorageBackend::Sqlite);
329    }
330
331    // ── load_graph_auto tests ──────────────────────────────
332
333    #[test]
334    fn test_load_graph_auto_yaml() {
335        let tmp = TempDir::new().unwrap();
336        let yaml = "project:\n  name: test\nnodes:\n  - id: n1\n    title: Node 1\nedges: []\n";
337        fs::write(tmp.path().join("graph.yml"), yaml).unwrap();
338
339        let graph = load_graph_auto(tmp.path(), None).unwrap();
340        assert_eq!(graph.project.as_ref().unwrap().name, "test");
341        assert_eq!(graph.nodes.len(), 1);
342        assert_eq!(graph.nodes[0].id, "n1");
343    }
344
345    #[test]
346    fn test_load_graph_auto_empty_dir_returns_default() {
347        let tmp = TempDir::new().unwrap();
348        let graph = load_graph_auto(tmp.path(), None).unwrap();
349        assert!(graph.nodes.is_empty());
350        assert!(graph.edges.is_empty());
351    }
352
353    #[test]
354    fn test_load_graph_auto_explicit_yaml_override() {
355        let tmp = TempDir::new().unwrap();
356        let yaml = "nodes:\n  - id: y1\n    title: YAML node\nedges: []\n";
357        fs::write(tmp.path().join("graph.yml"), yaml).unwrap();
358        // Create a graph.db too — but explicit YAML should win
359        // (We don't create a real DB here since explicit override skips detection)
360
361        let graph = load_graph_auto(tmp.path(), Some(StorageBackend::Yaml)).unwrap();
362        assert_eq!(graph.nodes.len(), 1);
363        assert_eq!(graph.nodes[0].id, "y1");
364    }
365
366    // ── save_graph_auto tests ──────────────────────────────
367
368    #[test]
369    fn test_save_graph_auto_yaml() {
370        let tmp = TempDir::new().unwrap();
371        let graph = crate::Graph {
372            project: Some(crate::graph::ProjectMeta {
373                name: "test-save".into(),
374                description: None,
375            }),
376            nodes: vec![crate::graph::Node::new("s1", "Saved 1")],
377            edges: vec![],
378        };
379
380        save_graph_auto(&graph, tmp.path(), Some(StorageBackend::Yaml)).unwrap();
381        assert!(tmp.path().join("graph.yml").exists());
382
383        // Verify roundtrip
384        let loaded = load_graph_auto(tmp.path(), Some(StorageBackend::Yaml)).unwrap();
385        assert_eq!(loaded.nodes.len(), 1);
386        assert_eq!(loaded.nodes[0].id, "s1");
387        assert_eq!(loaded.project.unwrap().name, "test-save");
388    }
389
390    // ── SQLite bridge tests (require sqlite feature) ───────
391
392    #[cfg(feature = "sqlite")]
393    mod sqlite_bridge {
394        use super::*;
395        use crate::graph::{Node, Edge, NodeStatus, ProjectMeta};
396        use crate::task_graph_knowledge::KnowledgeNode;
397        use std::collections::HashMap;
398
399        #[test]
400        fn test_save_and_load_roundtrip() {
401            let tmp = TempDir::new().unwrap();
402            let db_path = tmp.path().join("graph.db");
403
404            let graph = crate::Graph {
405                project: Some(ProjectMeta {
406                    name: "roundtrip-test".into(),
407                    description: Some("Testing roundtrip".into()),
408                }),
409                nodes: vec![
410                    Node::new("a", "Alpha"),
411                    Node::new("b", "Beta"),
412                ],
413                edges: vec![
414                    Edge::new("a", "b", "depends_on"),
415                ],
416            };
417
418            save_graph_to_sqlite(&graph, &db_path).unwrap();
419            let loaded = load_graph_from_sqlite(&db_path).unwrap();
420
421            assert_eq!(loaded.project.as_ref().unwrap().name, "roundtrip-test");
422            assert_eq!(loaded.project.as_ref().unwrap().description.as_deref(), Some("Testing roundtrip"));
423            assert_eq!(loaded.nodes.len(), 2);
424            assert_eq!(loaded.edges.len(), 1);
425            assert_eq!(loaded.edges[0].from, "a");
426            assert_eq!(loaded.edges[0].to, "b");
427            assert_eq!(loaded.edges[0].relation, "depends_on");
428        }
429
430        #[test]
431        fn test_roundtrip_with_tags_and_metadata() {
432            let tmp = TempDir::new().unwrap();
433            let db_path = tmp.path().join("graph.db");
434
435            let mut node = Node::new("tagged", "Tagged Node");
436            node.tags = vec!["urgent".into(), "backend".into()];
437            node.metadata.insert("priority".into(), serde_json::json!("high"));
438            node.metadata.insert("count".into(), serde_json::json!(42));
439
440            let graph = crate::Graph {
441                project: None,
442                nodes: vec![node],
443                edges: vec![],
444            };
445
446            save_graph_to_sqlite(&graph, &db_path).unwrap();
447            let loaded = load_graph_from_sqlite(&db_path).unwrap();
448
449            let n = &loaded.nodes[0];
450            assert_eq!(n.tags.len(), 2);
451            assert!(n.tags.contains(&"urgent".into()));
452            assert!(n.tags.contains(&"backend".into()));
453            assert_eq!(n.metadata.get("priority"), Some(&serde_json::json!("high")));
454            assert_eq!(n.metadata.get("count"), Some(&serde_json::json!(42)));
455        }
456
457        #[test]
458        fn test_roundtrip_with_knowledge() {
459            let tmp = TempDir::new().unwrap();
460            let db_path = tmp.path().join("graph.db");
461
462            let mut node = Node::new("knowledgeable", "Smart Node");
463            node.knowledge = KnowledgeNode {
464                findings: HashMap::from([
465                    ("FINDING-1".into(), "Bug found in parser".into()),
466                ]),
467                file_cache: HashMap::from([
468                    ("src/main.rs".into(), "fn main() {}".into()),
469                ]),
470                tool_history: vec![],
471            };
472
473            let graph = crate::Graph {
474                project: None,
475                nodes: vec![node],
476                edges: vec![],
477            };
478
479            save_graph_to_sqlite(&graph, &db_path).unwrap();
480            let loaded = load_graph_from_sqlite(&db_path).unwrap();
481
482            let n = &loaded.nodes[0];
483            assert_eq!(n.knowledge.findings.get("FINDING-1").unwrap(), "Bug found in parser");
484            assert_eq!(n.knowledge.file_cache.get("src/main.rs").unwrap(), "fn main() {}");
485        }
486
487        #[test]
488        fn test_roundtrip_node_statuses() {
489            let tmp = TempDir::new().unwrap();
490            let db_path = tmp.path().join("graph.db");
491
492            let mut todo = Node::new("t1", "Todo");
493            todo.status = NodeStatus::Todo;
494            let mut done = Node::new("t2", "Done");
495            done.status = NodeStatus::Done;
496            let mut ip = Node::new("t3", "InProgress");
497            ip.status = NodeStatus::InProgress;
498
499            let graph = crate::Graph {
500                project: None,
501                nodes: vec![todo, done, ip],
502                edges: vec![],
503            };
504
505            save_graph_to_sqlite(&graph, &db_path).unwrap();
506            let loaded = load_graph_from_sqlite(&db_path).unwrap();
507
508            let find = |id: &str| loaded.nodes.iter().find(|n| n.id == id).unwrap();
509            assert_eq!(find("t1").status, NodeStatus::Todo);
510            assert_eq!(find("t2").status, NodeStatus::Done);
511            assert_eq!(find("t3").status, NodeStatus::InProgress);
512        }
513
514        #[test]
515        fn test_roundtrip_edge_metadata() {
516            let tmp = TempDir::new().unwrap();
517            let db_path = tmp.path().join("graph.db");
518
519            let mut edge = Edge::new("a", "b", "calls");
520            edge.weight = Some(0.8);
521            edge.confidence = Some(0.95);
522
523            let graph = crate::Graph {
524                project: None,
525                nodes: vec![Node::new("a", "A"), Node::new("b", "B")],
526                edges: vec![edge],
527            };
528
529            save_graph_to_sqlite(&graph, &db_path).unwrap();
530            let loaded = load_graph_from_sqlite(&db_path).unwrap();
531
532            assert_eq!(loaded.edges.len(), 1);
533            assert!((loaded.edges[0].weight.unwrap() - 0.8).abs() < 0.001);
534            assert!((loaded.edges[0].confidence.unwrap() - 0.95).abs() < 0.001);
535        }
536
537        #[test]
538        fn test_roundtrip_empty_graph() {
539            let tmp = TempDir::new().unwrap();
540            let db_path = tmp.path().join("graph.db");
541
542            let graph = crate::Graph::default();
543            save_graph_to_sqlite(&graph, &db_path).unwrap();
544            let loaded = load_graph_from_sqlite(&db_path).unwrap();
545
546            assert!(loaded.nodes.is_empty());
547            assert!(loaded.edges.is_empty());
548        }
549
550        #[test]
551        fn test_save_overwrites_existing_data() {
552            let tmp = TempDir::new().unwrap();
553            let db_path = tmp.path().join("graph.db");
554
555            // First save
556            let graph1 = crate::Graph {
557                project: Some(ProjectMeta { name: "v1".into(), description: None }),
558                nodes: vec![Node::new("old", "Old Node")],
559                edges: vec![],
560            };
561            save_graph_to_sqlite(&graph1, &db_path).unwrap();
562
563            // Second save — should replace
564            let graph2 = crate::Graph {
565                project: Some(ProjectMeta { name: "v2".into(), description: None }),
566                nodes: vec![Node::new("new", "New Node")],
567                edges: vec![],
568            };
569            save_graph_to_sqlite(&graph2, &db_path).unwrap();
570
571            let loaded = load_graph_from_sqlite(&db_path).unwrap();
572            assert_eq!(loaded.project.unwrap().name, "v2");
573            assert_eq!(loaded.nodes.len(), 1);
574            assert_eq!(loaded.nodes[0].id, "new");
575        }
576
577        #[test]
578        fn test_load_graph_auto_detects_sqlite() {
579            let tmp = TempDir::new().unwrap();
580            let db_path = tmp.path().join("graph.db");
581
582            let graph = crate::Graph {
583                project: Some(ProjectMeta { name: "auto-sqlite".into(), description: None }),
584                nodes: vec![Node::new("x", "X")],
585                edges: vec![],
586            };
587            save_graph_to_sqlite(&graph, &db_path).unwrap();
588
589            // Auto-detect should find graph.db and use SQLite
590            let loaded = load_graph_auto(tmp.path(), None).unwrap();
591            assert_eq!(loaded.project.unwrap().name, "auto-sqlite");
592            assert_eq!(loaded.nodes.len(), 1);
593        }
594
595        #[test]
596        fn test_save_graph_auto_sqlite() {
597            let tmp = TempDir::new().unwrap();
598
599            let graph = crate::Graph {
600                project: Some(ProjectMeta { name: "auto-save".into(), description: None }),
601                nodes: vec![Node::new("as1", "Auto Saved")],
602                edges: vec![],
603            };
604
605            save_graph_auto(&graph, tmp.path(), Some(StorageBackend::Sqlite)).unwrap();
606            assert!(tmp.path().join("graph.db").exists());
607
608            let loaded = load_graph_auto(tmp.path(), None).unwrap();
609            assert_eq!(loaded.project.unwrap().name, "auto-save");
610            assert_eq!(loaded.nodes[0].id, "as1");
611        }
612
613        #[test]
614        fn test_roundtrip_many_nodes_and_edges() {
615            let tmp = TempDir::new().unwrap();
616            let db_path = tmp.path().join("graph.db");
617
618            let nodes: Vec<Node> = (0..50).map(|i| {
619                let mut n = Node::new(&format!("n{}", i), &format!("Node {}", i));
620                n.tags = vec![format!("group-{}", i % 5)];
621                n
622            }).collect();
623            let edges: Vec<Edge> = (0..49).map(|i| {
624                Edge::new(&format!("n{}", i), &format!("n{}", i + 1), "depends_on")
625            }).collect();
626
627            let graph = crate::Graph {
628                project: Some(ProjectMeta { name: "big".into(), description: None }),
629                nodes,
630                edges,
631            };
632
633            save_graph_to_sqlite(&graph, &db_path).unwrap();
634            let loaded = load_graph_from_sqlite(&db_path).unwrap();
635
636            assert_eq!(loaded.nodes.len(), 50);
637            assert_eq!(loaded.edges.len(), 49);
638            // Verify tags survived
639            let n0 = loaded.nodes.iter().find(|n| n.id == "n0").unwrap();
640            assert!(n0.tags.contains(&"group-0".into()));
641        }
642
643        #[test]
644        fn test_roundtrip_code_node_fields() {
645            let tmp = TempDir::new().unwrap();
646            let db_path = tmp.path().join("graph.db");
647
648            let mut node = Node::new("fn:main", "main");
649            node.node_type = Some("function".into());
650            node.file_path = Some("src/main.rs".into());
651            node.lang = Some("rust".into());
652            node.start_line = Some(1);
653            node.end_line = Some(10);
654            node.signature = Some("fn main() -> Result<()>".into());
655            node.visibility = Some("pub".into());
656            node.doc_comment = Some("Entry point".into());
657            node.source = Some("code_extract".into());
658            node.body_hash = Some("abc123".into());
659
660            let graph = crate::Graph {
661                project: None,
662                nodes: vec![node],
663                edges: vec![],
664            };
665
666            save_graph_to_sqlite(&graph, &db_path).unwrap();
667            let loaded = load_graph_from_sqlite(&db_path).unwrap();
668
669            let n = &loaded.nodes[0];
670            assert_eq!(n.node_type.as_deref(), Some("function"));
671            assert_eq!(n.file_path.as_deref(), Some("src/main.rs"));
672            assert_eq!(n.lang.as_deref(), Some("rust"));
673            assert_eq!(n.start_line, Some(1));
674            assert_eq!(n.end_line, Some(10));
675            assert_eq!(n.signature.as_deref(), Some("fn main() -> Result<()>"));
676            assert_eq!(n.visibility.as_deref(), Some("pub"));
677            assert_eq!(n.doc_comment.as_deref(), Some("Entry point"));
678            assert_eq!(n.source.as_deref(), Some("code_extract"));
679        }
680
681        #[test]
682        fn test_edge_deduplication_in_load() {
683            let tmp = TempDir::new().unwrap();
684            let db_path = tmp.path().join("graph.db");
685
686            // Create graph with bidirectional edges that share nodes
687            let graph = crate::Graph {
688                project: None,
689                nodes: vec![
690                    Node::new("a", "A"),
691                    Node::new("b", "B"),
692                    Node::new("c", "C"),
693                ],
694                edges: vec![
695                    Edge::new("a", "b", "calls"),
696                    Edge::new("b", "c", "calls"),
697                    Edge::new("a", "c", "depends_on"),
698                ],
699            };
700
701            save_graph_to_sqlite(&graph, &db_path).unwrap();
702            let loaded = load_graph_from_sqlite(&db_path).unwrap();
703
704            // Should have exactly 3 edges, not duplicated
705            assert_eq!(loaded.edges.len(), 3);
706        }
707
708        #[test]
709        fn test_explicit_sqlite_when_both_exist() {
710            let tmp = TempDir::new().unwrap();
711
712            // Create YAML with different data
713            let yaml = "nodes:\n  - id: yaml-node\n    title: From YAML\nedges: []\n";
714            fs::write(tmp.path().join("graph.yml"), yaml).unwrap();
715
716            // Create SQLite with different data
717            let graph = crate::Graph {
718                project: None,
719                nodes: vec![Node::new("sqlite-node", "From SQLite")],
720                edges: vec![],
721            };
722            save_graph_to_sqlite(&graph, &tmp.path().join("graph.db")).unwrap();
723
724            // Explicit YAML → should get YAML data
725            let loaded_yaml = load_graph_auto(tmp.path(), Some(StorageBackend::Yaml)).unwrap();
726            assert_eq!(loaded_yaml.nodes[0].id, "yaml-node");
727
728            // Explicit SQLite → should get SQLite data
729            let loaded_sqlite = load_graph_auto(tmp.path(), Some(StorageBackend::Sqlite)).unwrap();
730            assert_eq!(loaded_sqlite.nodes[0].id, "sqlite-node");
731
732            // Auto → should prefer SQLite (migrated project)
733            let loaded_auto = load_graph_auto(tmp.path(), None).unwrap();
734            assert_eq!(loaded_auto.nodes[0].id, "sqlite-node");
735        }
736    }
737}