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        let db = Self { conn };
214        db.init_schema()?;
215        Ok(db)
216    }
217
218    /// Create an in-memory graph database (for testing).
219    pub fn in_memory() -> Result<Self> {
220        let conn = rusqlite::Connection::open_in_memory()?;
221        let db = Self { conn };
222        db.init_schema()?;
223        Ok(db)
224    }
225
226    fn init_schema(&self) -> Result<()> {
227        self.conn.execute_batch(
228            "
229            CREATE TABLE IF NOT EXISTS nodes (
230                id INTEGER PRIMARY KEY AUTOINCREMENT,
231                name TEXT NOT NULL,
232                kind TEXT NOT NULL,
233                language TEXT NOT NULL,
234                file_path TEXT NOT NULL,
235                line_start INTEGER NOT NULL,
236                line_end INTEGER NOT NULL,
237                content TEXT
238            );
239            CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
240            CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
241            CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
242
243            CREATE TABLE IF NOT EXISTS edges (
244                id INTEGER PRIMARY KEY AUTOINCREMENT,
245                from_id INTEGER NOT NULL REFERENCES nodes(id),
246                to_id INTEGER NOT NULL REFERENCES nodes(id),
247                kind TEXT NOT NULL,
248                confidence REAL NOT NULL DEFAULT 1.0
249            );
250            CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
251            CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);
252            CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
253
254            CREATE TABLE IF NOT EXISTS metadata (
255                key TEXT PRIMARY KEY,
256                value TEXT NOT NULL
257            );
258            "
259        )?;
260        Ok(())
261    }
262
263    /// Insert a node and return its ID.
264    pub fn insert_node(&self, node: &Node) -> Result<i64> {
265        self.conn.execute(
266            "INSERT INTO nodes (name, kind, language, file_path, line_start, line_end, content)
267             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
268            rusqlite::params![
269                node.name,
270                node.kind.to_string(),
271                node.language.to_string(),
272                node.file_path,
273                node.line_start,
274                node.line_end,
275                node.content,
276            ],
277        )?;
278        Ok(self.conn.last_insert_rowid())
279    }
280
281    /// Insert an edge.
282    pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
283        self.conn.execute(
284            "INSERT INTO edges (from_id, to_id, kind, confidence) VALUES (?1, ?2, ?3, ?4)",
285            rusqlite::params![edge.from_id, edge.to_id, edge.kind.to_string(), edge.confidence],
286        )?;
287        Ok(())
288    }
289
290    /// Search nodes by name (case-insensitive substring match).
291    pub fn search_nodes(&self, query: &str) -> Result<Vec<Node>> {
292        let mut stmt = self.conn.prepare(
293            "SELECT id, name, kind, language, file_path, line_start, line_end, content
294             FROM nodes WHERE name LIKE ?1 ORDER BY name"
295        )?;
296        let pattern = format!("%{}%", query);
297        let rows = stmt.query_map([&pattern], |row| {
298            Ok(Node {
299                id: row.get(0)?,
300                name: row.get(1)?,
301                kind: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(2)?))
302                    .unwrap_or(NodeKind::Function),
303                language: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(3)?))
304                    .unwrap_or(Language::Unknown),
305                file_path: row.get(4)?,
306                line_start: row.get(5)?,
307                line_end: row.get(6)?,
308                content: row.get(7)?,
309            })
310        })?;
311        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(DeagleError::from)
312    }
313
314    /// Get all edges from a node (outgoing relationships).
315    pub fn edges_from(&self, node_id: i64) -> Result<Vec<Edge>> {
316        let mut stmt = self.conn.prepare(
317            "SELECT from_id, to_id, kind, confidence FROM edges WHERE from_id = ?1"
318        )?;
319        let rows = stmt.query_map([node_id], |row| {
320            Ok(Edge {
321                from_id: row.get(0)?,
322                to_id: row.get(1)?,
323                kind: serde_json::from_str(&format!("\"{}\"", row.get::<_, String>(2)?))
324                    .unwrap_or(EdgeKind::Calls),
325                confidence: row.get(3)?,
326            })
327        })?;
328        rows.collect::<std::result::Result<Vec<_>, _>>().map_err(DeagleError::from)
329    }
330
331    /// Get total node count.
332    pub fn node_count(&self) -> Result<usize> {
333        let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
334        Ok(count as usize)
335    }
336
337    /// Get total edge count.
338    pub fn edge_count(&self) -> Result<usize> {
339        let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
340        Ok(count as usize)
341    }
342
343    /// Clear all data (for re-indexing).
344    pub fn clear(&self) -> Result<()> {
345        self.conn.execute_batch("DELETE FROM edges; DELETE FROM nodes;")?;
346        Ok(())
347    }
348
349    /// Get the database file path.
350    pub fn path(&self) -> Option<PathBuf> {
351        self.conn.path().map(PathBuf::from)
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_language_from_extension() {
361        assert_eq!(Language::from_extension("rs"), Language::Rust);
362        assert_eq!(Language::from_extension("py"), Language::Python);
363        assert_eq!(Language::from_extension("go"), Language::Go);
364        assert_eq!(Language::from_extension("ts"), Language::TypeScript);
365        assert_eq!(Language::from_extension("tsx"), Language::TypeScript);
366        assert_eq!(Language::from_extension("js"), Language::JavaScript);
367        assert_eq!(Language::from_extension("java"), Language::Java);
368        assert_eq!(Language::from_extension("cpp"), Language::Cpp);
369        assert_eq!(Language::from_extension("c"), Language::C);
370        assert_eq!(Language::from_extension("xyz"), Language::Unknown);
371    }
372
373    #[test]
374    fn test_language_display() {
375        assert_eq!(Language::Rust.to_string(), "rust");
376        assert_eq!(Language::Python.to_string(), "python");
377    }
378
379    #[test]
380    fn test_node_kind_display() {
381        assert_eq!(NodeKind::Function.to_string(), "function");
382        assert_eq!(NodeKind::Struct.to_string(), "struct");
383        assert_eq!(NodeKind::TypeAlias.to_string(), "type_alias");
384    }
385
386    #[test]
387    fn test_edge_kind_display() {
388        assert_eq!(EdgeKind::Calls.to_string(), "calls");
389        assert_eq!(EdgeKind::Imports.to_string(), "imports");
390        assert_eq!(EdgeKind::Contains.to_string(), "contains");
391    }
392
393    #[test]
394    fn test_graph_db_in_memory() {
395        let db = GraphDb::in_memory().unwrap();
396        assert_eq!(db.node_count().unwrap(), 0);
397        assert_eq!(db.edge_count().unwrap(), 0);
398    }
399
400    #[test]
401    fn test_insert_and_search_node() {
402        let db = GraphDb::in_memory().unwrap();
403        let node = Node {
404            id: 0,
405            name: "process_request".to_string(),
406            kind: NodeKind::Function,
407            language: Language::Rust,
408            file_path: "src/handler.rs".to_string(),
409            line_start: 42,
410            line_end: 68,
411            content: Some("pub fn process_request() {}".to_string()),
412        };
413        let id = db.insert_node(&node).unwrap();
414        assert!(id > 0);
415        assert_eq!(db.node_count().unwrap(), 1);
416
417        let results = db.search_nodes("process").unwrap();
418        assert_eq!(results.len(), 1);
419        assert_eq!(results[0].name, "process_request");
420        assert_eq!(results[0].kind, NodeKind::Function);
421        assert_eq!(results[0].language, Language::Rust);
422    }
423
424    #[test]
425    fn test_insert_edge_and_query() {
426        let db = GraphDb::in_memory().unwrap();
427        let n1 = Node {
428            id: 0, name: "main".into(), kind: NodeKind::Function,
429            language: Language::Rust, file_path: "src/main.rs".into(),
430            line_start: 1, line_end: 10, content: None,
431        };
432        let n2 = Node {
433            id: 0, name: "handler".into(), kind: NodeKind::Function,
434            language: Language::Rust, file_path: "src/lib.rs".into(),
435            line_start: 5, line_end: 20, content: None,
436        };
437        let id1 = db.insert_node(&n1).unwrap();
438        let id2 = db.insert_node(&n2).unwrap();
439
440        let edge = Edge {
441            from_id: id1, to_id: id2,
442            kind: EdgeKind::Calls, confidence: 1.0,
443        };
444        db.insert_edge(&edge).unwrap();
445        assert_eq!(db.edge_count().unwrap(), 1);
446
447        let edges = db.edges_from(id1).unwrap();
448        assert_eq!(edges.len(), 1);
449        assert_eq!(edges[0].to_id, id2);
450        assert_eq!(edges[0].kind, EdgeKind::Calls);
451    }
452
453    #[test]
454    fn test_search_case_insensitive() {
455        let db = GraphDb::in_memory().unwrap();
456        let node = Node {
457            id: 0, name: "MyStruct".into(), kind: NodeKind::Struct,
458            language: Language::Rust, file_path: "src/types.rs".into(),
459            line_start: 1, line_end: 5, content: None,
460        };
461        db.insert_node(&node).unwrap();
462
463        let results = db.search_nodes("mystruct").unwrap();
464        assert_eq!(results.len(), 1);
465    }
466
467    #[test]
468    fn test_clear_db() {
469        let db = GraphDb::in_memory().unwrap();
470        let node = Node {
471            id: 0, name: "test".into(), kind: NodeKind::Function,
472            language: Language::Rust, file_path: "t.rs".into(),
473            line_start: 1, line_end: 1, content: None,
474        };
475        db.insert_node(&node).unwrap();
476        assert_eq!(db.node_count().unwrap(), 1);
477        db.clear().unwrap();
478        assert_eq!(db.node_count().unwrap(), 0);
479    }
480
481    #[test]
482    fn test_node_serialization() {
483        let node = Node {
484            id: 1, name: "test_fn".into(), kind: NodeKind::Function,
485            language: Language::Python, file_path: "app.py".into(),
486            line_start: 10, line_end: 25, content: Some("def test_fn(): pass".into()),
487        };
488        let json = serde_json::to_string(&node).unwrap();
489        let parsed: Node = serde_json::from_str(&json).unwrap();
490        assert_eq!(parsed.name, "test_fn");
491        assert_eq!(parsed.kind, NodeKind::Function);
492        assert_eq!(parsed.language, Language::Python);
493    }
494
495    #[test]
496    fn test_language_extensions() {
497        assert!(Language::Rust.extensions().contains(&"rs"));
498        assert!(Language::TypeScript.extensions().contains(&"tsx"));
499        assert!(Language::Unknown.extensions().is_empty());
500    }
501
502    #[test]
503    fn test_multiple_nodes_same_name() {
504        let db = GraphDb::in_memory().unwrap();
505        for file in &["a.rs", "b.rs", "c.rs"] {
506            db.insert_node(&Node {
507                id: 0, name: "new".into(), kind: NodeKind::Method,
508                language: Language::Rust, file_path: file.to_string(),
509                line_start: 1, line_end: 5, content: None,
510            }).unwrap();
511        }
512        let results = db.search_nodes("new").unwrap();
513        assert_eq!(results.len(), 3, "Should find all 3 nodes named 'new'");
514    }
515
516    #[test]
517    fn test_search_empty_query() {
518        let db = GraphDb::in_memory().unwrap();
519        db.insert_node(&Node {
520            id: 0, name: "hello".into(), kind: NodeKind::Function,
521            language: Language::Rust, file_path: "t.rs".into(),
522            line_start: 1, line_end: 1, content: None,
523        }).unwrap();
524        // Empty pattern matches everything via LIKE '%%'
525        let results = db.search_nodes("").unwrap();
526        assert_eq!(results.len(), 1);
527    }
528
529    #[test]
530    fn test_edges_from_nonexistent_node() {
531        let db = GraphDb::in_memory().unwrap();
532        let edges = db.edges_from(999).unwrap();
533        assert!(edges.is_empty());
534    }
535
536    #[test]
537    fn test_multiple_edge_types() {
538        let db = GraphDb::in_memory().unwrap();
539        let id1 = db.insert_node(&Node {
540            id: 0, name: "A".into(), kind: NodeKind::Struct,
541            language: Language::Rust, file_path: "a.rs".into(),
542            line_start: 1, line_end: 5, content: None,
543        }).unwrap();
544        let id2 = db.insert_node(&Node {
545            id: 0, name: "B".into(), kind: NodeKind::Trait,
546            language: Language::Rust, file_path: "b.rs".into(),
547            line_start: 1, line_end: 5, content: None,
548        }).unwrap();
549
550        db.insert_edge(&Edge { from_id: id1, to_id: id2, kind: EdgeKind::Implements, confidence: 1.0 }).unwrap();
551        db.insert_edge(&Edge { from_id: id1, to_id: id2, kind: EdgeKind::References, confidence: 0.8 }).unwrap();
552
553        let edges = db.edges_from(id1).unwrap();
554        assert_eq!(edges.len(), 2);
555        assert!(edges.iter().any(|e| e.kind == EdgeKind::Implements));
556        assert!(edges.iter().any(|e| e.kind == EdgeKind::References));
557    }
558
559    #[test]
560    fn test_edge_confidence_stored() {
561        let db = GraphDb::in_memory().unwrap();
562        let id1 = db.insert_node(&Node {
563            id: 0, name: "x".into(), kind: NodeKind::Function,
564            language: Language::Rust, file_path: "x.rs".into(),
565            line_start: 1, line_end: 1, content: None,
566        }).unwrap();
567        let id2 = db.insert_node(&Node {
568            id: 0, name: "y".into(), kind: NodeKind::Function,
569            language: Language::Rust, file_path: "y.rs".into(),
570            line_start: 1, line_end: 1, content: None,
571        }).unwrap();
572
573        db.insert_edge(&Edge { from_id: id1, to_id: id2, kind: EdgeKind::Calls, confidence: 0.75 }).unwrap();
574        let edges = db.edges_from(id1).unwrap();
575        assert!((edges[0].confidence - 0.75).abs() < 0.01);
576    }
577
578    #[test]
579    fn test_node_with_none_content() {
580        let node = Node {
581            id: 0, name: "no_content".into(), kind: NodeKind::Function,
582            language: Language::Go, file_path: "main.go".into(),
583            line_start: 1, line_end: 10, content: None,
584        };
585        let json = serde_json::to_string(&node).unwrap();
586        let parsed: Node = serde_json::from_str(&json).unwrap();
587        assert!(parsed.content.is_none());
588        assert_eq!(parsed.language, Language::Go);
589    }
590}