Skip to main content

deagle_core/
lib.rs

1//! deagle-core — graph types and SQLite storage for code intelligence.
2//!
3//! Defines the code graph model: nodes (functions, classes, modules),
4//! edges (calls, imports, contains, inherits), and SQLite-backed persistence.
5//!
6//! ## Feature Flags
7//!
8//! - `semantic` — enables semantic code search via [ares-vector](https://crates.io/crates/ares-vector)
9
10#[cfg(feature = "semantic")]
11pub mod semantic;
12
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16/// A node in the code graph — represents a code entity.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct Node {
19    /// Unique identifier (auto-generated)
20    pub id: i64,
21    /// Entity name (function name, class name, etc.)
22    pub name: String,
23    /// Entity kind
24    pub kind: NodeKind,
25    /// Programming language
26    pub language: Language,
27    /// Source file path (relative to repo root)
28    pub file_path: String,
29    /// Start line number (1-indexed)
30    pub line_start: u32,
31    /// End line number (1-indexed)
32    pub line_end: u32,
33    /// Optional source code excerpt
34    pub content: Option<String>,
35}
36
37/// Kind of code entity.
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
39#[serde(rename_all = "snake_case")]
40pub enum NodeKind {
41    File,
42    Module,
43    Function,
44    Method,
45    Class,
46    Struct,
47    Enum,
48    Trait,
49    Interface,
50    Constant,
51    Variable,
52    TypeAlias,
53    Import,
54}
55
56impl std::fmt::Display for NodeKind {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        let s = match self {
59            Self::File => "file",
60            Self::Module => "module",
61            Self::Function => "function",
62            Self::Method => "method",
63            Self::Class => "class",
64            Self::Struct => "struct",
65            Self::Enum => "enum",
66            Self::Trait => "trait",
67            Self::Interface => "interface",
68            Self::Constant => "constant",
69            Self::Variable => "variable",
70            Self::TypeAlias => "type_alias",
71            Self::Import => "import",
72        };
73        write!(f, "{}", s)
74    }
75}
76
77/// Supported programming languages.
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
79#[serde(rename_all = "lowercase")]
80pub enum Language {
81    Rust,
82    Python,
83    Go,
84    TypeScript,
85    JavaScript,
86    Java,
87    Cpp,
88    C,
89    Ruby,
90    Unknown,
91}
92
93impl Language {
94    /// Detect language from file extension.
95    pub fn from_extension(ext: &str) -> Self {
96        match ext.to_lowercase().as_str() {
97            "rs" => Self::Rust,
98            "py" => Self::Python,
99            "go" => Self::Go,
100            "ts" | "tsx" => Self::TypeScript,
101            "js" | "jsx" | "mjs" | "cjs" => Self::JavaScript,
102            "java" => Self::Java,
103            "cpp" | "cc" | "cxx" | "hpp" => Self::Cpp,
104            "c" | "h" => Self::C,
105            "rb" | "rake" | "gemspec" => Self::Ruby,
106            _ => Self::Unknown,
107        }
108    }
109
110    /// File extensions for this language.
111    pub fn extensions(&self) -> &[&str] {
112        match self {
113            Self::Rust => &["rs"],
114            Self::Python => &["py"],
115            Self::Go => &["go"],
116            Self::TypeScript => &["ts", "tsx"],
117            Self::JavaScript => &["js", "jsx", "mjs", "cjs"],
118            Self::Java => &["java"],
119            Self::Cpp => &["cpp", "cc", "cxx", "hpp"],
120            Self::C => &["c", "h"],
121            Self::Ruby => &["rb", "rake", "gemspec"],
122            Self::Unknown => &[],
123        }
124    }
125}
126
127impl std::fmt::Display for Language {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let s = match self {
130            Self::Rust => "rust",
131            Self::Python => "python",
132            Self::Go => "go",
133            Self::TypeScript => "typescript",
134            Self::JavaScript => "javascript",
135            Self::Java => "java",
136            Self::Cpp => "cpp",
137            Self::C => "c",
138            Self::Ruby => "ruby",
139            Self::Unknown => "unknown",
140        };
141        write!(f, "{}", s)
142    }
143}
144
145/// An edge in the code graph — represents a relationship between entities.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct Edge {
148    /// Source node ID
149    pub from_id: i64,
150    /// Target node ID
151    pub to_id: i64,
152    /// Relationship type
153    pub kind: EdgeKind,
154    /// Confidence score (0.0–1.0) for inferred edges
155    pub confidence: f32,
156}
157
158/// Kind of relationship between code entities.
159#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
160#[serde(rename_all = "snake_case")]
161pub enum EdgeKind {
162    /// Function/method call
163    Calls,
164    /// Import/use statement
165    Imports,
166    /// Parent contains child (file→function, class→method)
167    Contains,
168    /// Class/struct inheritance
169    Inherits,
170    /// Interface/trait implementation
171    Implements,
172    /// Type reference (parameter type, return type, field type)
173    References,
174    /// Module/package dependency
175    DependsOn,
176}
177
178impl std::fmt::Display for EdgeKind {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        let s = match self {
181            Self::Calls => "calls",
182            Self::Imports => "imports",
183            Self::Contains => "contains",
184            Self::Inherits => "inherits",
185            Self::Implements => "implements",
186            Self::References => "references",
187            Self::DependsOn => "depends_on",
188        };
189        write!(f, "{}", s)
190    }
191}
192
193/// Errors from deagle operations.
194#[derive(Debug, thiserror::Error)]
195pub enum DeagleError {
196    #[cfg(feature = "sqlite")]
197    #[error("Database error: {0}")]
198    Database(#[from] rusqlite::Error),
199    #[error("IO error: {0}")]
200    Io(#[from] std::io::Error),
201    #[error("Parse error in {file}: {message}")]
202    Parse { file: String, message: String },
203    #[error("{0}")]
204    Other(String),
205}
206
207pub type Result<T> = std::result::Result<T, DeagleError>;
208
209#[cfg(feature = "sqlite")]
210/// SQLite-backed code graph database.
211pub struct GraphDb {
212    conn: rusqlite::Connection,
213}
214
215#[cfg(feature = "sqlite")]
216impl GraphDb {
217    /// Open or create a graph database at the given path.
218    pub fn open(path: &std::path::Path) -> Result<Self> {
219        let conn = rusqlite::Connection::open(path)?;
220        // WAL mode: concurrent reads during writes, faster for indexing workloads
221        conn.pragma_update(None, "journal_mode", "WAL")?;
222        // Synchronous NORMAL: safe with WAL, 2-3x faster than FULL
223        conn.pragma_update(None, "synchronous", "NORMAL")?;
224        let db = Self { conn };
225        db.init_schema()?;
226        Ok(db)
227    }
228
229    /// Create an in-memory graph database (for testing).
230    pub fn in_memory() -> Result<Self> {
231        let conn = rusqlite::Connection::open_in_memory()?;
232        let db = Self { conn };
233        db.init_schema()?;
234        Ok(db)
235    }
236
237    fn init_schema(&self) -> Result<()> {
238        self.conn.execute_batch(
239            "
240            CREATE TABLE IF NOT EXISTS nodes (
241                id INTEGER PRIMARY KEY AUTOINCREMENT,
242                name TEXT NOT NULL,
243                kind TEXT NOT NULL,
244                language TEXT NOT NULL,
245                file_path TEXT NOT NULL,
246                line_start INTEGER NOT NULL,
247                line_end INTEGER NOT NULL,
248                content TEXT
249            );
250            CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
251            CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
252            CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
253
254            CREATE TABLE IF NOT EXISTS edges (
255                id INTEGER PRIMARY KEY AUTOINCREMENT,
256                from_id INTEGER NOT NULL REFERENCES nodes(id),
257                to_id INTEGER NOT NULL REFERENCES nodes(id),
258                kind TEXT NOT NULL,
259                confidence REAL NOT NULL DEFAULT 1.0
260            );
261            CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
262            CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);
263            CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
264
265            CREATE TABLE IF NOT EXISTS metadata (
266                key TEXT PRIMARY KEY,
267                value TEXT NOT NULL
268            );
269
270            CREATE TABLE IF NOT EXISTS file_hashes (
271                file_path TEXT PRIMARY KEY,
272                content_hash TEXT NOT NULL,
273                indexed_at TEXT NOT NULL DEFAULT (datetime('now'))
274            );
275
276            CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
277                name, content, file_path,
278                content='nodes',
279                content_rowid='id'
280            );
281            "
282        )?;
283        Ok(())
284    }
285
286    /// Insert a node and return its ID.
287    pub fn insert_node(&self, node: &Node) -> Result<i64> {
288        self.conn.execute(
289            "INSERT INTO nodes (name, kind, language, file_path, line_start, line_end, content)
290             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
291            rusqlite::params![
292                node.name,
293                node.kind.to_string(),
294                node.language.to_string(),
295                node.file_path,
296                node.line_start,
297                node.line_end,
298                node.content,
299            ],
300        )?;
301        let id = self.conn.last_insert_rowid();
302        // Populate FTS5 index
303        self.conn.execute(
304            "INSERT INTO nodes_fts(rowid, name, content, file_path) VALUES (?1, ?2, ?3, ?4)",
305            rusqlite::params![id, node.name, node.content, node.file_path],
306        )?;
307        Ok(id)
308    }
309
310    /// Batch insert nodes and edges in a single transaction (much faster for indexing).
311    pub fn insert_batch(&self, nodes: &[Node], edges: &[(i64, i64, EdgeKind)]) -> Result<Vec<i64>> {
312        let tx = self.conn.unchecked_transaction()?;
313        let mut ids = Vec::with_capacity(nodes.len());
314
315        {
316            let mut node_stmt = tx.prepare_cached(
317                "INSERT INTO nodes (name, kind, language, file_path, line_start, line_end, content)
318                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
319            )?;
320            let mut fts_stmt = tx.prepare_cached(
321                "INSERT INTO nodes_fts(rowid, name, content, file_path) VALUES (?1, ?2, ?3, ?4)"
322            )?;
323
324            for node in nodes {
325                node_stmt.execute(rusqlite::params![
326                    node.name, node.kind.to_string(), node.language.to_string(),
327                    node.file_path, node.line_start, node.line_end, node.content,
328                ])?;
329                let id = tx.last_insert_rowid();
330                fts_stmt.execute(rusqlite::params![id, node.name, node.content, node.file_path])?;
331                ids.push(id);
332            }
333        }
334
335        {
336            let mut edge_stmt = tx.prepare_cached(
337                "INSERT INTO edges (from_id, to_id, kind, confidence) VALUES (?1, ?2, ?3, ?4)"
338            )?;
339            for (from_id, to_id, kind) in edges {
340                edge_stmt.execute(rusqlite::params![from_id, to_id, kind.to_string(), 1.0])?;
341            }
342        }
343
344        tx.commit()?;
345        Ok(ids)
346    }
347
348    /// Full-text keyword search using FTS5 BM25 ranking.
349    pub fn keyword_search(&self, query: &str) -> Result<Vec<Node>> {
350        let mut stmt = self.conn.prepare(
351            "SELECT n.id, n.name, n.kind, n.language, n.file_path, n.line_start, n.line_end, n.content
352             FROM nodes_fts f
353             JOIN nodes n ON n.id = f.rowid
354             WHERE nodes_fts MATCH ?1
355             ORDER BY rank
356             LIMIT 50"
357        )?;
358        let rows = stmt.query_map([query], |row| {
359            Ok(Node {
360                id: row.get(0)?,
361                name: row.get(1)?,
362                kind: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(2)?))
363                    .unwrap_or(NodeKind::Function),
364                language: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(3)?))
365                    .unwrap_or(Language::Unknown),
366                file_path: row.get(4)?,
367                line_start: row.get(5)?,
368                line_end: row.get(6)?,
369                content: row.get(7)?,
370            })
371        })?;
372        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(DeagleError::from)
373    }
374
375    /// Insert an edge.
376    pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
377        self.conn.execute(
378            "INSERT INTO edges (from_id, to_id, kind, confidence) VALUES (?1, ?2, ?3, ?4)",
379            rusqlite::params![edge.from_id, edge.to_id, edge.kind.to_string(), edge.confidence],
380        )?;
381        Ok(())
382    }
383
384    /// Search nodes by name (case-insensitive substring match).
385    pub fn search_nodes(&self, query: &str) -> Result<Vec<Node>> {
386        let mut stmt = self.conn.prepare(
387            "SELECT id, name, kind, language, file_path, line_start, line_end, content
388             FROM nodes WHERE name LIKE ?1 ORDER BY name"
389        )?;
390        let pattern = format!("%{}%", query);
391        let rows = stmt.query_map([&pattern], |row| {
392            Ok(Node {
393                id: row.get(0)?,
394                name: row.get(1)?,
395                kind: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(2)?))
396                    .unwrap_or(NodeKind::Function),
397                language: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(3)?))
398                    .unwrap_or(Language::Unknown),
399                file_path: row.get(4)?,
400                line_start: row.get(5)?,
401                line_end: row.get(6)?,
402                content: row.get(7)?,
403            })
404        })?;
405        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(DeagleError::from)
406    }
407
408    /// Fuzzy search nodes by name — ranked by match score (best first).
409    pub fn fuzzy_search_nodes(&self, query: &str) -> Result<Vec<Node>> {
410        use fuzzy_matcher::skim::SkimMatcherV2;
411        use fuzzy_matcher::FuzzyMatcher;
412
413        let matcher = SkimMatcherV2::default();
414
415        // Get all nodes and score them
416        let mut stmt = self.conn.prepare(
417            "SELECT id, name, kind, language, file_path, line_start, line_end, content FROM nodes"
418        )?;
419        let rows = stmt.query_map([], |row| {
420            Ok(Node {
421                id: row.get(0)?,
422                name: row.get(1)?,
423                kind: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(2)?))
424                    .unwrap_or(NodeKind::Function),
425                language: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(3)?))
426                    .unwrap_or(Language::Unknown),
427                file_path: row.get(4)?,
428                line_start: row.get(5)?,
429                line_end: row.get(6)?,
430                content: row.get(7)?,
431            })
432        })?;
433
434        let all_nodes: Vec<Node> = rows.collect::<std::result::Result<Vec<_>, _>>()?;
435
436        let mut scored: Vec<(i64, Node)> = all_nodes
437            .into_iter()
438            .filter_map(|node| {
439                matcher.fuzzy_match(&node.name, query).map(|score| (score, node))
440            })
441            .collect();
442
443        // Sort by score descending (best matches first)
444        scored.sort_by(|a, b| b.0.cmp(&a.0));
445
446        Ok(scored.into_iter().map(|(_, node)| node).collect())
447    }
448
449    /// Get all edges from a node (outgoing relationships).
450    pub fn edges_from(&self, node_id: i64) -> Result<Vec<Edge>> {
451        let mut stmt = self.conn.prepare(
452            "SELECT from_id, to_id, kind, confidence FROM edges WHERE from_id = ?1"
453        )?;
454        let rows = stmt.query_map([node_id], |row| {
455            Ok(Edge {
456                from_id: row.get(0)?,
457                to_id: row.get(1)?,
458                kind: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(2)?))
459                    .unwrap_or(EdgeKind::Calls),
460                confidence: row.get(3)?,
461            })
462        })?;
463        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(DeagleError::from)
464    }
465
466    /// Get total node count.
467    pub fn node_count(&self) -> Result<usize> {
468        let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
469        Ok(count as usize)
470    }
471
472    /// Get total edge count.
473    pub fn edge_count(&self) -> Result<usize> {
474        let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
475        Ok(count as usize)
476    }
477
478    /// Clear all data (for re-indexing).
479    pub fn clear(&self) -> Result<()> {
480        self.conn.execute_batch("DELETE FROM edges; DELETE FROM nodes_fts; DELETE FROM nodes; DELETE FROM file_hashes;")?;
481        Ok(())
482    }
483
484    /// Get the database file path.
485    pub fn path(&self) -> Option<PathBuf> {
486        self.conn.path().map(PathBuf::from)
487    }
488
489    /// Compute SHA-256 hash of content (first 16 hex chars).
490    pub fn content_hash(content: &str) -> String {
491        use sha2::{Sha256, Digest};
492        let hash = Sha256::digest(content.as_bytes());
493        hash.iter().take(8).map(|b| format!("{:02x}", b)).collect()
494    }
495
496    /// Check if a file needs re-indexing (hash changed or new file).
497    pub fn needs_reindex(&self, file_path: &str, content: &str) -> Result<bool> {
498        let current_hash = Self::content_hash(content);
499        let stored: Option<String> = self.conn.query_row(
500            "SELECT content_hash FROM file_hashes WHERE file_path = ?1",
501            [file_path],
502            |row| row.get(0),
503        ).ok();
504
505        Ok(stored.as_deref() != Some(&current_hash))
506    }
507
508    /// Store file hash after indexing.
509    pub fn store_file_hash(&self, file_path: &str, content: &str) -> Result<()> {
510        let hash = Self::content_hash(content);
511        self.conn.execute(
512            "INSERT OR REPLACE INTO file_hashes (file_path, content_hash) VALUES (?1, ?2)",
513            rusqlite::params![file_path, hash],
514        )?;
515        Ok(())
516    }
517
518    /// Remove nodes and edges for a specific file (for re-indexing).
519    pub fn remove_file(&self, file_path: &str) -> Result<()> {
520        // Get node IDs for this file
521        let mut stmt = self.conn.prepare("SELECT id FROM nodes WHERE file_path = ?1")?;
522        let ids: Vec<i64> = stmt.query_map([file_path], |row| row.get(0))?
523            .filter_map(|r| r.ok())
524            .collect();
525
526        // Delete edges referencing these nodes
527        for id in &ids {
528            self.conn.execute("DELETE FROM edges WHERE from_id = ?1 OR to_id = ?1", [id])?;
529        }
530        // Delete nodes
531        self.conn.execute("DELETE FROM nodes WHERE file_path = ?1", [file_path])?;
532        // Delete hash
533        self.conn.execute("DELETE FROM file_hashes WHERE file_path = ?1", [file_path])?;
534        Ok(())
535    }
536}
537
538#[cfg(all(test, feature = "sqlite"))]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_language_from_extension() {
544        assert_eq!(Language::from_extension("rs"), Language::Rust);
545        assert_eq!(Language::from_extension("py"), Language::Python);
546        assert_eq!(Language::from_extension("go"), Language::Go);
547        assert_eq!(Language::from_extension("ts"), Language::TypeScript);
548        assert_eq!(Language::from_extension("tsx"), Language::TypeScript);
549        assert_eq!(Language::from_extension("js"), Language::JavaScript);
550        assert_eq!(Language::from_extension("java"), Language::Java);
551        assert_eq!(Language::from_extension("cpp"), Language::Cpp);
552        assert_eq!(Language::from_extension("c"), Language::C);
553        assert_eq!(Language::from_extension("xyz"), Language::Unknown);
554    }
555
556    #[test]
557    fn test_language_display() {
558        assert_eq!(Language::Rust.to_string(), "rust");
559        assert_eq!(Language::Python.to_string(), "python");
560    }
561
562    #[test]
563    fn test_node_kind_display() {
564        assert_eq!(NodeKind::Function.to_string(), "function");
565        assert_eq!(NodeKind::Struct.to_string(), "struct");
566        assert_eq!(NodeKind::TypeAlias.to_string(), "type_alias");
567    }
568
569    #[test]
570    fn test_edge_kind_display() {
571        assert_eq!(EdgeKind::Calls.to_string(), "calls");
572        assert_eq!(EdgeKind::Imports.to_string(), "imports");
573        assert_eq!(EdgeKind::Contains.to_string(), "contains");
574    }
575
576    #[test]
577    fn test_graph_db_in_memory() {
578        let db = GraphDb::in_memory().unwrap();
579        assert_eq!(db.node_count().unwrap(), 0);
580        assert_eq!(db.edge_count().unwrap(), 0);
581    }
582
583    #[test]
584    fn test_insert_and_search_node() {
585        let db = GraphDb::in_memory().unwrap();
586        let node = Node {
587            id: 0,
588            name: "process_request".to_string(),
589            kind: NodeKind::Function,
590            language: Language::Rust,
591            file_path: "src/handler.rs".to_string(),
592            line_start: 42,
593            line_end: 68,
594            content: Some("pub fn process_request() {}".to_string()),
595        };
596        let id = db.insert_node(&node).unwrap();
597        assert!(id > 0);
598        assert_eq!(db.node_count().unwrap(), 1);
599
600        let results = db.search_nodes("process").unwrap();
601        assert_eq!(results.len(), 1);
602        assert_eq!(results[0].name, "process_request");
603        assert_eq!(results[0].kind, NodeKind::Function);
604        assert_eq!(results[0].language, Language::Rust);
605    }
606
607    #[test]
608    fn test_insert_edge_and_query() {
609        let db = GraphDb::in_memory().unwrap();
610        let n1 = Node {
611            id: 0, name: "main".into(), kind: NodeKind::Function,
612            language: Language::Rust, file_path: "src/main.rs".into(),
613            line_start: 1, line_end: 10, content: None,
614        };
615        let n2 = Node {
616            id: 0, name: "handler".into(), kind: NodeKind::Function,
617            language: Language::Rust, file_path: "src/lib.rs".into(),
618            line_start: 5, line_end: 20, content: None,
619        };
620        let id1 = db.insert_node(&n1).unwrap();
621        let id2 = db.insert_node(&n2).unwrap();
622
623        let edge = Edge {
624            from_id: id1, to_id: id2,
625            kind: EdgeKind::Calls, confidence: 1.0,
626        };
627        db.insert_edge(&edge).unwrap();
628        assert_eq!(db.edge_count().unwrap(), 1);
629
630        let edges = db.edges_from(id1).unwrap();
631        assert_eq!(edges.len(), 1);
632        assert_eq!(edges[0].to_id, id2);
633        assert_eq!(edges[0].kind, EdgeKind::Calls);
634    }
635
636    #[test]
637    fn test_search_case_insensitive() {
638        let db = GraphDb::in_memory().unwrap();
639        let node = Node {
640            id: 0, name: "MyStruct".into(), kind: NodeKind::Struct,
641            language: Language::Rust, file_path: "src/types.rs".into(),
642            line_start: 1, line_end: 5, content: None,
643        };
644        db.insert_node(&node).unwrap();
645
646        let results = db.search_nodes("mystruct").unwrap();
647        assert_eq!(results.len(), 1);
648    }
649
650    #[test]
651    fn test_clear_db() {
652        let db = GraphDb::in_memory().unwrap();
653        let node = Node {
654            id: 0, name: "test".into(), kind: NodeKind::Function,
655            language: Language::Rust, file_path: "t.rs".into(),
656            line_start: 1, line_end: 1, content: None,
657        };
658        db.insert_node(&node).unwrap();
659        assert_eq!(db.node_count().unwrap(), 1);
660        db.clear().unwrap();
661        assert_eq!(db.node_count().unwrap(), 0);
662    }
663
664    #[test]
665    fn test_node_serialization() {
666        let node = Node {
667            id: 1, name: "test_fn".into(), kind: NodeKind::Function,
668            language: Language::Python, file_path: "app.py".into(),
669            line_start: 10, line_end: 25, content: Some("def test_fn(): pass".into()),
670        };
671        let json = serde_json::to_string(&node).unwrap();
672        let parsed: Node = serde_json::from_str(&json).unwrap();
673        assert_eq!(parsed.name, "test_fn");
674        assert_eq!(parsed.kind, NodeKind::Function);
675        assert_eq!(parsed.language, Language::Python);
676    }
677
678    #[test]
679    fn test_language_extensions() {
680        assert!(Language::Rust.extensions().contains(&"rs"));
681        assert!(Language::TypeScript.extensions().contains(&"tsx"));
682        assert!(Language::Unknown.extensions().is_empty());
683    }
684
685    #[test]
686    fn test_multiple_nodes_same_name() {
687        let db = GraphDb::in_memory().unwrap();
688        for file in &["a.rs", "b.rs", "c.rs"] {
689            db.insert_node(&Node {
690                id: 0, name: "new".into(), kind: NodeKind::Method,
691                language: Language::Rust, file_path: file.to_string(),
692                line_start: 1, line_end: 5, content: None,
693            }).unwrap();
694        }
695        let results = db.search_nodes("new").unwrap();
696        assert_eq!(results.len(), 3, "Should find all 3 nodes named 'new'");
697    }
698
699    #[test]
700    fn test_search_empty_query() {
701        let db = GraphDb::in_memory().unwrap();
702        db.insert_node(&Node {
703            id: 0, name: "hello".into(), kind: NodeKind::Function,
704            language: Language::Rust, file_path: "t.rs".into(),
705            line_start: 1, line_end: 1, content: None,
706        }).unwrap();
707        // Empty pattern matches everything via LIKE '%%'
708        let results = db.search_nodes("").unwrap();
709        assert_eq!(results.len(), 1);
710    }
711
712    #[test]
713    fn test_edges_from_nonexistent_node() {
714        let db = GraphDb::in_memory().unwrap();
715        let edges = db.edges_from(999).unwrap();
716        assert!(edges.is_empty());
717    }
718
719    #[test]
720    fn test_multiple_edge_types() {
721        let db = GraphDb::in_memory().unwrap();
722        let id1 = db.insert_node(&Node {
723            id: 0, name: "A".into(), kind: NodeKind::Struct,
724            language: Language::Rust, file_path: "a.rs".into(),
725            line_start: 1, line_end: 5, content: None,
726        }).unwrap();
727        let id2 = db.insert_node(&Node {
728            id: 0, name: "B".into(), kind: NodeKind::Trait,
729            language: Language::Rust, file_path: "b.rs".into(),
730            line_start: 1, line_end: 5, content: None,
731        }).unwrap();
732
733        db.insert_edge(&Edge { from_id: id1, to_id: id2, kind: EdgeKind::Implements, confidence: 1.0 }).unwrap();
734        db.insert_edge(&Edge { from_id: id1, to_id: id2, kind: EdgeKind::References, confidence: 0.8 }).unwrap();
735
736        let edges = db.edges_from(id1).unwrap();
737        assert_eq!(edges.len(), 2);
738        assert!(edges.iter().any(|e| e.kind == EdgeKind::Implements));
739        assert!(edges.iter().any(|e| e.kind == EdgeKind::References));
740    }
741
742    #[test]
743    fn test_edge_confidence_stored() {
744        let db = GraphDb::in_memory().unwrap();
745        let id1 = db.insert_node(&Node {
746            id: 0, name: "x".into(), kind: NodeKind::Function,
747            language: Language::Rust, file_path: "x.rs".into(),
748            line_start: 1, line_end: 1, content: None,
749        }).unwrap();
750        let id2 = db.insert_node(&Node {
751            id: 0, name: "y".into(), kind: NodeKind::Function,
752            language: Language::Rust, file_path: "y.rs".into(),
753            line_start: 1, line_end: 1, content: None,
754        }).unwrap();
755
756        db.insert_edge(&Edge { from_id: id1, to_id: id2, kind: EdgeKind::Calls, confidence: 0.75 }).unwrap();
757        let edges = db.edges_from(id1).unwrap();
758        assert!((edges[0].confidence - 0.75).abs() < 0.01);
759    }
760
761    #[test]
762    #[test]
763    fn test_fuzzy_search_basic() {
764        let db = GraphDb::in_memory().unwrap();
765        for name in &["process_request", "handle_response", "parse_input", "validate_data"] {
766            db.insert_node(&Node {
767                id: 0, name: name.to_string(), kind: NodeKind::Function,
768                language: Language::Rust, file_path: "lib.rs".into(),
769                line_start: 1, line_end: 5, content: None,
770            }).unwrap();
771        }
772
773        let results = db.fuzzy_search_nodes("proc").unwrap();
774        assert!(!results.is_empty(), "fuzzy search should find matches");
775        assert_eq!(results[0].name, "process_request", "best match should be first");
776    }
777
778    #[test]
779    fn test_fuzzy_search_typo_tolerance() {
780        let db = GraphDb::in_memory().unwrap();
781        db.insert_node(&Node {
782            id: 0, name: "calculate_total".into(), kind: NodeKind::Function,
783            language: Language::Rust, file_path: "math.rs".into(),
784            line_start: 1, line_end: 5, content: None,
785        }).unwrap();
786        db.insert_node(&Node {
787            id: 0, name: "validate_input".into(), kind: NodeKind::Function,
788            language: Language::Rust, file_path: "input.rs".into(),
789            line_start: 1, line_end: 5, content: None,
790        }).unwrap();
791
792        // "calctot" should fuzzy-match "calculate_total"
793        let results = db.fuzzy_search_nodes("calctot").unwrap();
794        assert!(!results.is_empty());
795        assert_eq!(results[0].name, "calculate_total");
796    }
797
798    #[test]
799    fn test_fuzzy_search_no_match() {
800        let db = GraphDb::in_memory().unwrap();
801        db.insert_node(&Node {
802            id: 0, name: "hello".into(), kind: NodeKind::Function,
803            language: Language::Rust, file_path: "t.rs".into(),
804            line_start: 1, line_end: 1, content: None,
805        }).unwrap();
806
807        let results = db.fuzzy_search_nodes("zzzzz").unwrap();
808        assert!(results.is_empty(), "no fuzzy match for gibberish");
809    }
810
811    #[test]
812    fn test_fuzzy_search_empty_db() {
813        let db = GraphDb::in_memory().unwrap();
814        let results = db.fuzzy_search_nodes("anything").unwrap();
815        assert!(results.is_empty());
816    }
817
818    #[test]
819    fn test_keyword_search() {
820        let db = GraphDb::in_memory().unwrap();
821        db.insert_node(&Node {
822            id: 0, name: "process_data".into(), kind: NodeKind::Function,
823            language: Language::Rust, file_path: "data.rs".into(),
824            line_start: 1, line_end: 10,
825            content: Some("pub fn process_data(input: Vec<String>) -> Result<()>".into()),
826        }).unwrap();
827        db.insert_node(&Node {
828            id: 0, name: "validate".into(), kind: NodeKind::Function,
829            language: Language::Rust, file_path: "val.rs".into(),
830            line_start: 1, line_end: 5, content: Some("fn validate(s: &str) -> bool".into()),
831        }).unwrap();
832
833        let results = db.keyword_search("process").unwrap();
834        assert!(!results.is_empty(), "FTS5 should find 'process'");
835        assert_eq!(results[0].name, "process_data");
836    }
837
838    #[test]
839    fn test_keyword_search_content() {
840        let db = GraphDb::in_memory().unwrap();
841        db.insert_node(&Node {
842            id: 0, name: "handler".into(), kind: NodeKind::Function,
843            language: Language::Rust, file_path: "web.rs".into(),
844            line_start: 1, line_end: 20,
845            content: Some("async fn handler(req: Request) -> Response { authenticate(req) }".into()),
846        }).unwrap();
847
848        // Search by content, not name
849        let results = db.keyword_search("authenticate").unwrap();
850        assert!(!results.is_empty(), "FTS5 should search content too");
851    }
852
853    #[test]
854    fn test_keyword_search_empty() {
855        let db = GraphDb::in_memory().unwrap();
856        db.insert_node(&Node {
857            id: 0, name: "hello".into(), kind: NodeKind::Function,
858            language: Language::Rust, file_path: "h.rs".into(),
859            line_start: 1, line_end: 1, content: None,
860        }).unwrap();
861
862        let results = db.keyword_search("nonexistent_xyz").unwrap();
863        assert!(results.is_empty());
864    }
865
866    #[test]
867    fn test_incremental_hash() {
868        let db = GraphDb::in_memory().unwrap();
869        let content = "fn main() {}";
870        assert!(db.needs_reindex("test.rs", content).unwrap(), "new file needs indexing");
871        db.store_file_hash("test.rs", content).unwrap();
872        assert!(!db.needs_reindex("test.rs", content).unwrap(), "same content skipped");
873        assert!(db.needs_reindex("test.rs", "fn main() { println!() }").unwrap(), "changed content needs re-index");
874    }
875
876    #[test]
877    fn test_node_with_none_content() {
878        let node = Node {
879            id: 0, name: "no_content".into(), kind: NodeKind::Function,
880            language: Language::Go, file_path: "main.go".into(),
881            line_start: 1, line_end: 10, content: None,
882        };
883        let json = serde_json::to_string(&node).unwrap();
884        let parsed: Node = serde_json::from_str(&json).unwrap();
885        assert!(parsed.content.is_none());
886        assert_eq!(parsed.language, Language::Go);
887    }
888}