1use anyhow::Context;
9use chrono::{DateTime, Utc};
10use parking_lot::Mutex;
11use rusqlite::{Connection, params};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use uuid::Uuid;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum NodeType {
23 Pattern,
24 Decision,
25 Lesson,
26 Expert,
27 Technology,
28}
29
30impl NodeType {
31 pub fn as_str(&self) -> &'static str {
32 match self {
33 Self::Pattern => "pattern",
34 Self::Decision => "decision",
35 Self::Lesson => "lesson",
36 Self::Expert => "expert",
37 Self::Technology => "technology",
38 }
39 }
40
41 pub fn parse(s: &str) -> anyhow::Result<Self> {
42 match s {
43 "pattern" => Ok(Self::Pattern),
44 "decision" => Ok(Self::Decision),
45 "lesson" => Ok(Self::Lesson),
46 "expert" => Ok(Self::Expert),
47 "technology" => Ok(Self::Technology),
48 other => anyhow::bail!("unknown node type: {other}"),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum Relation {
57 Uses,
58 Replaces,
59 Extends,
60 AuthoredBy,
61 AppliesTo,
62}
63
64impl Relation {
65 pub fn as_str(&self) -> &'static str {
66 match self {
67 Self::Uses => "uses",
68 Self::Replaces => "replaces",
69 Self::Extends => "extends",
70 Self::AuthoredBy => "authored_by",
71 Self::AppliesTo => "applies_to",
72 }
73 }
74
75 pub fn parse(s: &str) -> anyhow::Result<Self> {
76 match s {
77 "uses" => Ok(Self::Uses),
78 "replaces" => Ok(Self::Replaces),
79 "extends" => Ok(Self::Extends),
80 "authored_by" => Ok(Self::AuthoredBy),
81 "applies_to" => Ok(Self::AppliesTo),
82 other => anyhow::bail!("unknown relation: {other}"),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct KnowledgeNode {
90 pub id: String,
91 pub node_type: NodeType,
92 pub title: String,
93 pub content: String,
94 pub tags: Vec<String>,
95 pub created_at: DateTime<Utc>,
96 pub updated_at: DateTime<Utc>,
97 pub source_project: Option<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct KnowledgeEdge {
103 pub from_id: String,
104 pub to_id: String,
105 pub relation: Relation,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct SearchResult {
111 pub node: KnowledgeNode,
112 pub score: f64,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct GraphStats {
118 pub total_nodes: usize,
119 pub total_edges: usize,
120 pub nodes_by_type: HashMap<String, usize>,
121 pub top_tags: Vec<(String, usize)>,
122}
123
124pub struct KnowledgeGraph {
128 conn: Mutex<Connection>,
129 max_nodes: usize,
130}
131
132impl KnowledgeGraph {
133 pub fn new(db_path: &Path, max_nodes: usize) -> anyhow::Result<Self> {
135 if let Some(parent) = db_path.parent() {
136 std::fs::create_dir_all(parent)?;
137 }
138
139 let conn = Connection::open(db_path).context("failed to open knowledge graph database")?;
140
141 conn.execute_batch(
142 "PRAGMA journal_mode = WAL;
143 PRAGMA synchronous = NORMAL;
144 PRAGMA foreign_keys = ON;",
145 )?;
146
147 conn.execute_batch(
148 "CREATE TABLE IF NOT EXISTS nodes (
149 id TEXT PRIMARY KEY,
150 node_type TEXT NOT NULL,
151 title TEXT NOT NULL,
152 content TEXT NOT NULL,
153 tags TEXT NOT NULL DEFAULT '',
154 created_at TEXT NOT NULL,
155 updated_at TEXT NOT NULL,
156 source_project TEXT
157 );
158
159 CREATE TABLE IF NOT EXISTS edges (
160 from_id TEXT NOT NULL,
161 to_id TEXT NOT NULL,
162 relation TEXT NOT NULL,
163 PRIMARY KEY (from_id, to_id, relation),
164 FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE,
165 FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE
166 );
167
168 CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
169 title, content, tags, content='nodes', content_rowid='rowid'
170 );
171
172 CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
173 INSERT INTO nodes_fts(rowid, title, content, tags)
174 VALUES (new.rowid, new.title, new.content, new.tags);
175 END;
176
177 CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
178 INSERT INTO nodes_fts(nodes_fts, rowid, title, content, tags)
179 VALUES ('delete', old.rowid, old.title, old.content, old.tags);
180 END;
181
182 CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
183 INSERT INTO nodes_fts(nodes_fts, rowid, title, content, tags)
184 VALUES ('delete', old.rowid, old.title, old.content, old.tags);
185 INSERT INTO nodes_fts(rowid, title, content, tags)
186 VALUES (new.rowid, new.title, new.content, new.tags);
187 END;
188
189 CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(node_type);
190 CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source_project);
191 CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
192 CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);",
193 )?;
194
195 Ok(Self {
196 conn: Mutex::new(conn),
197 max_nodes,
198 })
199 }
200
201 pub fn add_node(
203 &self,
204 node_type: NodeType,
205 title: &str,
206 content: &str,
207 tags: &[String],
208 source_project: Option<&str>,
209 ) -> anyhow::Result<String> {
210 let conn = self.conn.lock();
211
212 let count: usize = conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
214 if count >= self.max_nodes {
215 anyhow::bail!(
216 "knowledge graph node limit reached ({}/{})",
217 count,
218 self.max_nodes
219 );
220 }
221
222 for tag in tags {
224 if tag.contains(',') {
225 anyhow::bail!(
226 "tag '{}' contains a comma, which is used as the tag separator",
227 tag
228 );
229 }
230 }
231
232 let id = Uuid::new_v4().to_string();
233 let now = Utc::now().to_rfc3339();
234 let tags_str = tags.join(",");
235
236 conn.execute(
237 "INSERT INTO nodes (id, node_type, title, content, tags, created_at, updated_at, source_project)
238 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
239 params![
240 id,
241 node_type.as_str(),
242 title,
243 content,
244 tags_str,
245 now,
246 now,
247 source_project,
248 ],
249 )?;
250
251 Ok(id)
252 }
253
254 pub fn add_edge(&self, from_id: &str, to_id: &str, relation: Relation) -> anyhow::Result<()> {
256 let conn = self.conn.lock();
257
258 let exists = |id: &str| -> anyhow::Result<bool> {
260 let c: usize = conn.query_row(
261 "SELECT COUNT(*) FROM nodes WHERE id = ?1",
262 params![id],
263 |r| r.get(0),
264 )?;
265 Ok(c > 0)
266 };
267
268 if !exists(from_id)? {
269 anyhow::bail!("source node not found: {from_id}");
270 }
271 if !exists(to_id)? {
272 anyhow::bail!("target node not found: {to_id}");
273 }
274
275 conn.execute(
276 "INSERT OR IGNORE INTO edges (from_id, to_id, relation) VALUES (?1, ?2, ?3)",
277 params![from_id, to_id, relation.as_str()],
278 )?;
279
280 Ok(())
281 }
282
283 pub fn get_node(&self, id: &str) -> anyhow::Result<Option<KnowledgeNode>> {
285 let conn = self.conn.lock();
286 let mut stmt = conn.prepare(
287 "SELECT id, node_type, title, content, tags, created_at, updated_at, source_project
288 FROM nodes WHERE id = ?1",
289 )?;
290
291 let mut rows = stmt.query(params![id])?;
292 match rows.next()? {
293 Some(row) => Ok(Some(row_to_node(row)?)),
294 None => Ok(None),
295 }
296 }
297
298 pub fn query_by_tags(&self, tags: &[String]) -> anyhow::Result<Vec<KnowledgeNode>> {
300 let conn = self.conn.lock();
301 let mut stmt = conn.prepare(
302 "SELECT id, node_type, title, content, tags, created_at, updated_at, source_project
303 FROM nodes ORDER BY updated_at DESC",
304 )?;
305
306 let mut results = Vec::new();
307 let mut rows = stmt.query([])?;
308 while let Some(row) = rows.next()? {
309 let node = row_to_node(row)?;
310 if tags.iter().all(|t| node.tags.contains(t)) {
311 results.push(node);
312 }
313 }
314 Ok(results)
315 }
316
317 pub fn query_by_similarity(
319 &self,
320 query: &str,
321 limit: usize,
322 ) -> anyhow::Result<Vec<SearchResult>> {
323 let conn = self.conn.lock();
324
325 let sanitized: String = query
327 .split_whitespace()
328 .map(|w| format!("\"{}\"", w.replace('"', "")))
329 .collect::<Vec<_>>()
330 .join(" ");
331
332 if sanitized.is_empty() {
333 return Ok(Vec::new());
334 }
335
336 let mut stmt = conn.prepare(
337 "SELECT n.id, n.node_type, n.title, n.content, n.tags,
338 n.created_at, n.updated_at, n.source_project,
339 rank
340 FROM nodes_fts f
341 JOIN nodes n ON n.rowid = f.rowid
342 WHERE nodes_fts MATCH ?1
343 ORDER BY rank
344 LIMIT ?2",
345 )?;
346
347 let mut results = Vec::new();
348 let mut rows = stmt.query(params![sanitized, limit as i64])?;
349 while let Some(row) = rows.next()? {
350 let node = row_to_node(row)?;
351 let rank: f64 = row.get(8)?;
352 results.push(SearchResult {
353 node,
354 score: -rank, });
356 }
357 Ok(results)
358 }
359
360 pub fn find_related(&self, node_id: &str) -> anyhow::Result<Vec<(KnowledgeNode, Relation)>> {
362 let conn = self.conn.lock();
363 let mut stmt = conn.prepare(
364 "SELECT n.id, n.node_type, n.title, n.content, n.tags,
365 n.created_at, n.updated_at, n.source_project,
366 e.relation
367 FROM edges e
368 JOIN nodes n ON n.id = e.to_id
369 WHERE e.from_id = ?1
370 UNION ALL
371 SELECT n.id, n.node_type, n.title, n.content, n.tags,
372 n.created_at, n.updated_at, n.source_project,
373 e.relation
374 FROM edges e
375 JOIN nodes n ON n.id = e.from_id
376 WHERE e.to_id = ?1",
377 )?;
378
379 let mut results = Vec::new();
380 let mut rows = stmt.query(params![node_id])?;
381 while let Some(row) = rows.next()? {
382 let node = row_to_node(row)?;
383 let relation_str: String = row.get(8)?;
384 let relation = Relation::parse(&relation_str)?;
385 results.push((node, relation));
386 }
387 Ok(results)
388 }
389
390 const MAX_SUBGRAPH_DEPTH: usize = 100;
392
393 pub fn get_subgraph(
398 &self,
399 root_id: &str,
400 depth: usize,
401 ) -> anyhow::Result<(Vec<KnowledgeNode>, Vec<KnowledgeEdge>)> {
402 if depth == 0 {
403 anyhow::bail!("subgraph depth must be greater than 0");
404 }
405 let depth = depth.min(Self::MAX_SUBGRAPH_DEPTH);
406 let conn = self.conn.lock();
407
408 let mut node_stmt = conn.prepare(
410 "WITH RECURSIVE reachable(id, depth) AS (
411 SELECT ?1, 0
412 UNION
413 SELECT CASE WHEN e.from_id = r.id THEN e.to_id ELSE e.from_id END, r.depth + 1
414 FROM reachable r
415 JOIN edges e ON e.from_id = r.id OR e.to_id = r.id
416 WHERE r.depth < ?2
417 )
418 SELECT DISTINCT n.id, n.node_type, n.title, n.content, n.tags,
419 n.created_at, n.updated_at, n.source_project
420 FROM reachable rc
421 JOIN nodes n ON n.id = rc.id",
422 )?;
423
424 let mut nodes = Vec::new();
425 let mut node_ids: HashSet<String> = HashSet::new();
426 let mut rows = node_stmt.query(params![root_id, depth as i64])?;
427 while let Some(row) = rows.next()? {
428 let node = row_to_node(row)?;
429 node_ids.insert(node.id.clone());
430 nodes.push(node);
431 }
432 drop(rows);
433
434 let mut edge_stmt = conn.prepare("SELECT from_id, to_id, relation FROM edges")?;
436
437 let mut edges = Vec::new();
438 let mut edge_rows = edge_stmt.query([])?;
439 while let Some(row) = edge_rows.next()? {
440 let from_id: String = row.get(0)?;
441 let to_id: String = row.get(1)?;
442 if node_ids.contains(&from_id) && node_ids.contains(&to_id) {
443 let relation_str: String = row.get(2)?;
444 let relation = Relation::parse(&relation_str)?;
445 edges.push(KnowledgeEdge {
446 from_id,
447 to_id,
448 relation,
449 });
450 }
451 }
452
453 Ok((nodes, edges))
454 }
455
456 pub fn find_experts(&self, tags: &[String]) -> anyhow::Result<Vec<SearchResult>> {
458 let matching = self.query_by_tags(tags)?;
460 let mut expert_scores: HashMap<String, f64> = HashMap::new();
461
462 let conn = self.conn.lock();
463 for node in &matching {
464 let mut stmt = conn.prepare(
465 "SELECT to_id FROM edges WHERE from_id = ?1 AND relation = 'authored_by'",
466 )?;
467 let mut rows = stmt.query(params![node.id])?;
468 while let Some(row) = rows.next()? {
469 let expert_id: String = row.get(0)?;
470 *expert_scores.entry(expert_id).or_default() += 1.0;
471 }
472 }
473 drop(conn);
474
475 let mut results: Vec<SearchResult> = Vec::new();
476 for (eid, score) in expert_scores {
477 if let Some(node) = self.get_node(&eid)?
478 && node.node_type == NodeType::Expert
479 {
480 results.push(SearchResult { node, score });
481 }
482 }
483
484 results.sort_by(|a, b| {
485 b.score
486 .partial_cmp(&a.score)
487 .unwrap_or(std::cmp::Ordering::Equal)
488 });
489 Ok(results)
490 }
491
492 pub fn stats(&self) -> anyhow::Result<GraphStats> {
494 let conn = self.conn.lock();
495
496 let total_nodes: usize = conn.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
497 let total_edges: usize = conn.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
498
499 let mut by_type = HashMap::new();
500 {
501 let mut stmt =
502 conn.prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type")?;
503 let mut rows = stmt.query([])?;
504 while let Some(row) = rows.next()? {
505 let t: String = row.get(0)?;
506 let c: usize = row.get(1)?;
507 by_type.insert(t, c);
508 }
509 }
510
511 let mut tag_counts: HashMap<String, usize> = HashMap::new();
513 {
514 let mut stmt = conn.prepare("SELECT tags FROM nodes WHERE tags != ''")?;
515 let mut rows = stmt.query([])?;
516 while let Some(row) = rows.next()? {
517 let tags_str: String = row.get(0)?;
518 for tag in tags_str.split(',') {
519 let tag = tag.trim();
520 if !tag.is_empty() {
521 *tag_counts.entry(tag.to_string()).or_default() += 1;
522 }
523 }
524 }
525 }
526 let mut top_tags: Vec<(String, usize)> = tag_counts.into_iter().collect();
527 top_tags.sort_by_key(|tag| std::cmp::Reverse(tag.1));
528 top_tags.truncate(10);
529
530 Ok(GraphStats {
531 total_nodes,
532 total_edges,
533 nodes_by_type: by_type,
534 top_tags,
535 })
536 }
537}
538
539fn row_to_node(row: &rusqlite::Row<'_>) -> anyhow::Result<KnowledgeNode> {
541 let id: String = row.get(0)?;
542 let node_type_str: String = row.get(1)?;
543 let title: String = row.get(2)?;
544 let content: String = row.get(3)?;
545 let tags_str: String = row.get(4)?;
546 let created_at_str: String = row.get(5)?;
547 let updated_at_str: String = row.get(6)?;
548 let source_project: Option<String> = row.get(7)?;
549
550 let tags: Vec<String> = tags_str
551 .split(',')
552 .map(|s| s.trim().to_string())
553 .filter(|s| !s.is_empty())
554 .collect();
555
556 let created_at = DateTime::parse_from_rfc3339(&created_at_str)
557 .map(|dt| dt.with_timezone(&Utc))
558 .unwrap_or_else(|_| Utc::now());
559 let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
560 .map(|dt| dt.with_timezone(&Utc))
561 .unwrap_or_else(|_| Utc::now());
562
563 Ok(KnowledgeNode {
564 id,
565 node_type: NodeType::parse(&node_type_str)?,
566 title,
567 content,
568 tags,
569 created_at,
570 updated_at,
571 source_project,
572 })
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use tempfile::TempDir;
579
580 fn test_graph() -> (TempDir, KnowledgeGraph) {
581 let tmp = TempDir::new().unwrap();
582 let db_path = tmp.path().join("knowledge.db");
583 let graph = KnowledgeGraph::new(&db_path, 1000).unwrap();
584 (tmp, graph)
585 }
586
587 #[test]
588 fn add_node_returns_unique_id() {
589 let (_tmp, graph) = test_graph();
590 let id1 = graph
591 .add_node(
592 NodeType::Pattern,
593 "Caching",
594 "Use Redis for caching",
595 &["redis".into()],
596 None,
597 )
598 .unwrap();
599 let id2 = graph
600 .add_node(NodeType::Lesson, "Lesson A", "Content A", &[], None)
601 .unwrap();
602 assert_ne!(id1, id2);
603 }
604
605 #[test]
606 fn get_node_returns_stored_data() {
607 let (_tmp, graph) = test_graph();
608 let id = graph
609 .add_node(
610 NodeType::Decision,
611 "Use Postgres",
612 "Chose Postgres over MySQL",
613 &["database".into(), "postgres".into()],
614 Some("project_alpha"),
615 )
616 .unwrap();
617
618 let node = graph.get_node(&id).unwrap().unwrap();
619 assert_eq!(node.title, "Use Postgres");
620 assert_eq!(node.node_type, NodeType::Decision);
621 assert_eq!(node.tags, vec!["database", "postgres"]);
622 assert_eq!(node.source_project.as_deref(), Some("project_alpha"));
623 }
624
625 #[test]
626 fn get_node_missing_returns_none() {
627 let (_tmp, graph) = test_graph();
628 assert!(graph.get_node("nonexistent").unwrap().is_none());
629 }
630
631 #[test]
632 fn add_edge_creates_relationship() {
633 let (_tmp, graph) = test_graph();
634 let id1 = graph
635 .add_node(NodeType::Pattern, "P1", "Pattern one", &[], None)
636 .unwrap();
637 let id2 = graph
638 .add_node(NodeType::Technology, "T1", "Tech one", &[], None)
639 .unwrap();
640
641 graph.add_edge(&id1, &id2, Relation::Uses).unwrap();
642
643 let related = graph.find_related(&id1).unwrap();
645 assert!(
646 related
647 .iter()
648 .any(|(n, r)| n.id == id2 && *r == Relation::Uses)
649 );
650
651 let related = graph.find_related(&id2).unwrap();
653 assert!(
654 related
655 .iter()
656 .any(|(n, r)| n.id == id1 && *r == Relation::Uses)
657 );
658 }
659
660 #[test]
661 fn add_edge_rejects_missing_node() {
662 let (_tmp, graph) = test_graph();
663 let id = graph
664 .add_node(NodeType::Lesson, "L1", "Lesson", &[], None)
665 .unwrap();
666 let err = graph
667 .add_edge(&id, "nonexistent", Relation::Extends)
668 .unwrap_err();
669 assert!(err.to_string().contains("target node not found"));
670 }
671
672 #[test]
673 fn query_by_tags_filters_correctly() {
674 let (_tmp, graph) = test_graph();
675 graph
676 .add_node(
677 NodeType::Pattern,
678 "P1",
679 "Content",
680 &["rust".into(), "async".into()],
681 None,
682 )
683 .unwrap();
684 graph
685 .add_node(NodeType::Pattern, "P2", "Content", &["rust".into()], None)
686 .unwrap();
687 graph
688 .add_node(NodeType::Pattern, "P3", "Content", &["python".into()], None)
689 .unwrap();
690
691 let results = graph.query_by_tags(&["rust".into()]).unwrap();
692 assert_eq!(results.len(), 2);
693
694 let results = graph
695 .query_by_tags(&["rust".into(), "async".into()])
696 .unwrap();
697 assert_eq!(results.len(), 1);
698 assert_eq!(results[0].title, "P1");
699 }
700
701 #[test]
702 fn query_by_similarity_returns_ranked_results() {
703 let (_tmp, graph) = test_graph();
704 graph
705 .add_node(
706 NodeType::Decision,
707 "Choose Rust for performance",
708 "Rust gives memory safety and speed",
709 &["rust".into()],
710 None,
711 )
712 .unwrap();
713 graph
714 .add_node(
715 NodeType::Lesson,
716 "Python scaling issues",
717 "Python had GIL bottleneck",
718 &["python".into()],
719 None,
720 )
721 .unwrap();
722
723 let results = graph.query_by_similarity("Rust performance", 10).unwrap();
724 assert!(!results.is_empty());
725 assert!(results[0].score > 0.0);
726 }
727
728 #[test]
729 fn subgraph_traversal_collects_connected_nodes() {
730 let (_tmp, graph) = test_graph();
731 let a = graph
732 .add_node(NodeType::Pattern, "A", "Node A", &[], None)
733 .unwrap();
734 let b = graph
735 .add_node(NodeType::Pattern, "B", "Node B", &[], None)
736 .unwrap();
737 let c = graph
738 .add_node(NodeType::Pattern, "C", "Node C", &[], None)
739 .unwrap();
740 graph.add_edge(&a, &b, Relation::Extends).unwrap();
741 graph.add_edge(&b, &c, Relation::Uses).unwrap();
742
743 let (nodes, edges) = graph.get_subgraph(&a, 2).unwrap();
745 assert_eq!(nodes.len(), 3);
746 assert_eq!(edges.len(), 2);
747
748 let (nodes, edges) = graph.get_subgraph(&c, 2).unwrap();
750 assert_eq!(nodes.len(), 3);
751 assert_eq!(edges.len(), 2);
752 }
753
754 #[test]
755 fn expert_ranking_by_authored_contributions() {
756 let (_tmp, graph) = test_graph();
757 let expert = graph
758 .add_node(
759 NodeType::Expert,
760 "zeroclaw_user",
761 "Backend expert",
762 &[],
763 None,
764 )
765 .unwrap();
766 let p1 = graph
767 .add_node(
768 NodeType::Pattern,
769 "Cache pattern",
770 "Redis caching",
771 &["caching".into()],
772 None,
773 )
774 .unwrap();
775 let p2 = graph
776 .add_node(
777 NodeType::Pattern,
778 "Queue pattern",
779 "Message queue",
780 &["caching".into()],
781 None,
782 )
783 .unwrap();
784
785 graph.add_edge(&p1, &expert, Relation::AuthoredBy).unwrap();
786 graph.add_edge(&p2, &expert, Relation::AuthoredBy).unwrap();
787
788 let experts = graph.find_experts(&["caching".into()]).unwrap();
789 assert_eq!(experts.len(), 1);
790 assert_eq!(experts[0].node.title, "zeroclaw_user");
791 assert!((experts[0].score - 2.0).abs() < f64::EPSILON);
792 }
793
794 #[test]
795 fn max_nodes_limit_enforced() {
796 let tmp = TempDir::new().unwrap();
797 let db_path = tmp.path().join("knowledge.db");
798 let graph = KnowledgeGraph::new(&db_path, 2).unwrap();
799
800 graph
801 .add_node(NodeType::Lesson, "L1", "C1", &[], None)
802 .unwrap();
803 graph
804 .add_node(NodeType::Lesson, "L2", "C2", &[], None)
805 .unwrap();
806 let err = graph
807 .add_node(NodeType::Lesson, "L3", "C3", &[], None)
808 .unwrap_err();
809 assert!(err.to_string().contains("node limit reached"));
810 }
811
812 #[test]
813 fn stats_reports_correct_counts() {
814 let (_tmp, graph) = test_graph();
815 graph
816 .add_node(NodeType::Pattern, "P", "C", &["rust".into()], None)
817 .unwrap();
818 graph
819 .add_node(
820 NodeType::Lesson,
821 "L",
822 "C",
823 &["rust".into(), "async".into()],
824 None,
825 )
826 .unwrap();
827
828 let stats = graph.stats().unwrap();
829 assert_eq!(stats.total_nodes, 2);
830 assert_eq!(stats.nodes_by_type.get("pattern"), Some(&1));
831 assert_eq!(stats.nodes_by_type.get("lesson"), Some(&1));
832 assert!(!stats.top_tags.is_empty());
833 }
834
835 #[test]
836 fn node_type_roundtrip() {
837 for nt in &[
838 NodeType::Pattern,
839 NodeType::Decision,
840 NodeType::Lesson,
841 NodeType::Expert,
842 NodeType::Technology,
843 ] {
844 assert_eq!(&NodeType::parse(nt.as_str()).unwrap(), nt);
845 }
846 }
847
848 #[test]
849 fn relation_roundtrip() {
850 for r in &[
851 Relation::Uses,
852 Relation::Replaces,
853 Relation::Extends,
854 Relation::AuthoredBy,
855 Relation::AppliesTo,
856 ] {
857 assert_eq!(&Relation::parse(r.as_str()).unwrap(), r);
858 }
859 }
860}