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 Ruby,
90 Unknown,
91}
92
93impl Language {
94 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct Edge {
148 pub from_id: i64,
150 pub to_id: i64,
152 pub kind: EdgeKind,
154 pub confidence: f32,
156}
157
158#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
160#[serde(rename_all = "snake_case")]
161pub enum EdgeKind {
162 Calls,
164 Imports,
166 Contains,
168 Inherits,
170 Implements,
172 References,
174 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#[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")]
210pub struct GraphDb {
212 conn: rusqlite::Connection,
213}
214
215#[cfg(feature = "sqlite")]
216impl GraphDb {
217 pub fn open(path: &std::path::Path) -> Result<Self> {
219 let conn = rusqlite::Connection::open(path)?;
220 conn.pragma_update(None, "journal_mode", "WAL")?;
222 conn.pragma_update(None, "synchronous", "NORMAL")?;
224 let db = Self { conn };
225 db.init_schema()?;
226 Ok(db)
227 }
228
229 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 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 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 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 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 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 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 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 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 scored.sort_by(|a, b| b.0.cmp(&a.0));
445
446 Ok(scored.into_iter().map(|(_, node)| node).collect())
447 }
448
449 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 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 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 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 pub fn path(&self) -> Option<PathBuf> {
486 self.conn.path().map(PathBuf::from)
487 }
488
489 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 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(¤t_hash))
506 }
507
508 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 pub fn remove_file(&self, file_path: &str) -> Result<()> {
520 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 for id in &ids {
528 self.conn.execute("DELETE FROM edges WHERE from_id = ?1 OR to_id = ?1", [id])?;
529 }
530 self.conn.execute("DELETE FROM nodes WHERE file_path = ?1", [file_path])?;
532 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 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 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 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}