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