1#[cfg(feature = "semantic")]
11pub mod semantic;
12
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct Node {
19 pub id: i64,
21 pub name: String,
23 pub kind: NodeKind,
25 pub language: Language,
27 pub file_path: String,
29 pub line_start: u32,
31 pub line_end: u32,
33 pub content: Option<String>,
35}
36
37#[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#[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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143pub struct Edge {
144 pub from_id: i64,
146 pub to_id: i64,
148 pub kind: EdgeKind,
150 pub confidence: f32,
152}
153
154#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
156#[serde(rename_all = "snake_case")]
157pub enum EdgeKind {
158 Calls,
160 Imports,
162 Contains,
164 Inherits,
166 Implements,
168 References,
170 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#[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
204pub struct GraphDb {
206 conn: rusqlite::Connection,
207}
208
209impl GraphDb {
210 pub fn open(path: &std::path::Path) -> Result<Self> {
212 let conn = rusqlite::Connection::open(path)?;
213 conn.pragma_update(None, "journal_mode", "WAL")?;
215 conn.pragma_update(None, "synchronous", "NORMAL")?;
217 let db = Self { conn };
218 db.init_schema()?;
219 Ok(db)
220 }
221
222 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 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 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 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 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 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 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 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 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 scored.sort_by(|a, b| b.0.cmp(&a.0));
438
439 Ok(scored.into_iter().map(|(_, node)| node).collect())
440 }
441
442 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 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 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 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 pub fn path(&self) -> Option<PathBuf> {
479 self.conn.path().map(PathBuf::from)
480 }
481
482 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 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(¤t_hash))
499 }
500
501 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 pub fn remove_file(&self, file_path: &str) -> Result<()> {
513 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 for id in &ids {
521 self.conn.execute("DELETE FROM edges WHERE from_id = ?1 OR to_id = ?1", [id])?;
522 }
523 self.conn.execute("DELETE FROM nodes WHERE file_path = ?1", [file_path])?;
525 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 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 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 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}