1use anyhow::Result;
2use libsql::{Builder, Connection, Database};
3use std::collections::HashMap;
4use std::env;
5
6pub const DEFAULT_SEARCH_LIMIT: usize = 100;
7pub use crate::constant::ENV_DATABASE_URL;
8
9pub async fn init_db() -> Result<(Database, Connection)> {
10 let paths = crate::paths::MikuPaths::resolve();
11 if !paths.data_dir.exists() {
12 std::fs::create_dir_all(&paths.data_dir)?;
13 }
14
15 let db_path = env::var(ENV_DATABASE_URL)
16 .unwrap_or_else(|_| paths.db_path().to_str().unwrap().to_string());
17 let db = Builder::new_local(&db_path).build().await?;
18 let conn = db.connect()?;
19
20 conn.execute(crate::constant::PRAGMA_FOREIGN_KEYS_ON, ())
21 .await?;
22
23 conn.execute(crate::constant::SCHEMA_CREATE_TOPICS, ())
24 .await?;
25
26 conn.execute(crate::constant::SCHEMA_CREATE_TOPICS_FTS, ())
28 .await?;
29
30 conn.execute(crate::constant::SCHEMA_CREATE_SESSIONS, ())
31 .await?;
32
33 conn.execute(crate::constant::SCHEMA_CREATE_MCP_ENTITIES, ())
35 .await?;
36
37 conn.execute(crate::constant::SCHEMA_CREATE_MCP_OBSERVATIONS, ())
38 .await?;
39
40 conn.execute(crate::constant::SCHEMA_CREATE_IDX_MCP_OBSERVATIONS, ())
41 .await?;
42
43 conn.execute(crate::constant::SCHEMA_CREATE_MCP_RELATIONS, ())
44 .await?;
45
46 conn.execute(crate::constant::SCHEMA_CREATE_MCP_TRUTHS, ())
47 .await?;
48
49 conn.execute(crate::constant::SCHEMA_CREATE_MCP_SKILLS, ())
50 .await?;
51
52 conn.execute(crate::constant::SCHEMA_CREATE_CHUNKS, ())
54 .await?;
55
56 conn.execute(crate::constant::SCHEMA_CREATE_IDX_CHUNKS_TOPIC_ID, ())
57 .await?;
58
59 conn.execute(crate::constant::SCHEMA_CREATE_IDX_CHUNKS_VECTOR, ())
61 .await?;
62
63 conn.execute(crate::constant::SCHEMA_CREATE_TRIGGER_TOPICS_AI, ())
65 .await?;
66 conn.execute(crate::constant::SCHEMA_CREATE_TRIGGER_TOPICS_AD, ())
67 .await?;
68 conn.execute(crate::constant::SCHEMA_CREATE_TRIGGER_TOPICS_AU, ())
69 .await?;
70
71 conn.execute(crate::constant::SCHEMA_CREATE_MCP_OBS_FTS, ())
73 .await?;
74
75 conn.execute(crate::constant::SCHEMA_CREATE_TRIGGER_MCP_OBS_AI, ())
77 .await?;
78 conn.execute(crate::constant::SCHEMA_CREATE_TRIGGER_MCP_OBS_AD, ())
79 .await?;
80 conn.execute(crate::constant::SCHEMA_CREATE_TRIGGER_MCP_OBS_AU, ())
81 .await?;
82
83 Ok((db, conn))
84}
85
86pub async fn search_fts(
88 conn: &Connection,
89 query: &str,
90 limit: usize,
91) -> Result<Vec<(String, String, String, f64)>> {
92 let mut rows = conn
93 .query(
94 crate::constant::SQL_SEARCH_FTS,
95 libsql::params![query, limit as i64],
96 )
97 .await?;
98 let mut results = Vec::new();
99 while let Some(row) = rows.next().await? {
100 results.push((
101 row.get::<String>(0)?,
102 row.get::<String>(1)?,
103 row.get::<String>(2)?,
104 row.get::<f64>(3)?,
105 ));
106 }
107 Ok(results)
108}
109
110pub async fn upsert_topic(
111 conn: &Connection,
112 id: &str,
113 title: &str,
114 file_path: &str,
115 body: &str,
116) -> Result<()> {
117 conn.execute(
118 crate::constant::SQL_UPSERT_TOPIC,
119 libsql::params![id, title, file_path, body],
120 )
121 .await?;
122 Ok(())
123}
124
125pub async fn delete_topic(conn: &Connection, id: &str) -> Result<()> {
126 conn.execute("DELETE FROM topics WHERE id = ?1", libsql::params![id])
127 .await?;
128 Ok(())
129}
130
131pub async fn mcp_create_entities(
132 conn: &Connection,
133 entities: Vec<crate::mcp::EntityInput>,
134) -> Result<()> {
135 let tx = conn.transaction().await?;
136 for mut ent in entities {
137 ent.name = crate::normalize::normalize_key(&ent.name);
138 tx.execute(
139 crate::constant::SQL_INSERT_ENTITY,
140 libsql::params![ent.name.clone(), ent.entity_type],
141 )
142 .await?;
143 for obs in ent.observations {
144 tx.execute(
145 crate::constant::SQL_INSERT_OBSERVATION,
146 libsql::params![uuid::Uuid::new_v4().to_string(), ent.name.clone(), obs],
147 )
148 .await?;
149 }
150 }
151 tx.commit().await?;
152 Ok(())
153}
154
155pub async fn mcp_add_observations(
156 conn: &Connection,
157 observations: Vec<crate::mcp::ObservationInput>,
158 limit: usize,
159) -> Result<()> {
160 let tx = conn.transaction().await?;
161 for mut obs_batch in observations {
162 obs_batch.entity_name = crate::normalize::normalize_key(&obs_batch.entity_name);
163 for content in obs_batch.contents {
164 tx.execute(
165 crate::constant::SQL_INSERT_OBSERVATION,
166 libsql::params![
167 uuid::Uuid::new_v4().to_string(),
168 obs_batch.entity_name.clone(),
169 content
170 ],
171 )
172 .await?;
173 }
174 if limit > 0 {
175 tx.execute(
176 crate::constant::SQL_EVICT_OBSERVATIONS,
177 libsql::params![obs_batch.entity_name.clone(), limit as i64],
178 )
179 .await?;
180 }
181 }
182 tx.commit().await?;
183 Ok(())
184}
185
186pub async fn mcp_create_relations(
187 conn: &Connection,
188 relations: Vec<crate::mcp::RelationInput>,
189) -> Result<()> {
190 let tx = conn.transaction().await?;
191 for mut rel in relations {
192 rel.from = crate::normalize::normalize_key(&rel.from);
193 rel.to = crate::normalize::normalize_key(&rel.to);
194 tx.execute(
195 crate::constant::SQL_INSERT_RELATION,
196 libsql::params![rel.from, rel.to, rel.relation_type],
197 )
198 .await?;
199 }
200 tx.commit().await?;
201 Ok(())
202}
203
204pub async fn mcp_delete_entities(conn: &Connection, names: Vec<String>) -> Result<()> {
205 let tx = conn.transaction().await?;
206 for name in names {
207 let norm_name = crate::normalize::normalize_key(&name);
208 tx.execute(
209 crate::constant::SQL_DELETE_ENTITY,
210 libsql::params![norm_name.clone()],
211 )
212 .await?;
213 tx.execute(
214 "DELETE FROM topics WHERE id = ?1",
215 libsql::params![norm_name.clone()],
216 )
217 .await?;
218 tx.execute(
219 "DELETE FROM chunks WHERE topic_id = ?1",
220 libsql::params![norm_name],
221 )
222 .await?;
223 }
224 tx.commit().await?;
225 Ok(())
226}
227
228pub async fn mcp_delete_observations(
229 conn: &Connection,
230 deletions: Vec<crate::mcp::ObservationDeletion>,
231) -> Result<()> {
232 let tx = conn.transaction().await?;
233 for mut del in deletions {
234 del.entity_name = crate::normalize::normalize_key(&del.entity_name);
235 for obs in del.observations {
236 tx.execute(
237 crate::constant::SQL_DELETE_OBSERVATION,
238 libsql::params![del.entity_name.clone(), obs],
239 )
240 .await?;
241 }
242 }
243 tx.commit().await?;
244 Ok(())
245}
246
247pub async fn mcp_delete_relations(
248 conn: &Connection,
249 relations: Vec<crate::mcp::RelationInput>,
250) -> Result<()> {
251 let tx = conn.transaction().await?;
252 for mut rel in relations {
253 rel.from = crate::normalize::normalize_key(&rel.from);
254 rel.to = crate::normalize::normalize_key(&rel.to);
255 tx.execute(
256 crate::constant::SQL_DELETE_RELATION,
257 libsql::params![rel.from, rel.to, rel.relation_type],
258 )
259 .await?;
260 }
261 tx.commit().await?;
262 Ok(())
263}
264
265pub async fn mcp_read_graph(conn: &Connection) -> Result<crate::mcp::Graph> {
266 let mut entity_names = Vec::new();
267 let mut rows = conn
268 .query(crate::constant::SQL_SELECT_ALL_ENTITIES, ())
269 .await?;
270 while let Some(row) = rows.next().await? {
271 entity_names.push(row.get::<String>(0)?);
272 }
273 let entities = load_entities_lazy(conn, &entity_names).await?;
274
275 let mut relations = Vec::new();
276 let mut rel_rows = conn
277 .query(crate::constant::SQL_SELECT_ALL_RELATIONS, ())
278 .await?;
279 while let Some(row) = rel_rows.next().await? {
280 relations.push(crate::mcp::RelationInput {
281 from: row.get(0)?,
282 to: row.get(1)?,
283 relation_type: row.get(2)?,
284 });
285 }
286
287 Ok(crate::mcp::Graph {
288 entities,
289 relations,
290 })
291}
292
293pub async fn mcp_read_graph_eager(conn: &Connection) -> Result<crate::mcp::Graph> {
294 let mut entity_names = Vec::new();
295 let mut rows = conn
296 .query(crate::constant::SQL_SELECT_ALL_ENTITIES, ())
297 .await?;
298 while let Some(row) = rows.next().await? {
299 entity_names.push(row.get::<String>(0)?);
300 }
301 let entities = load_entities_eager(conn, &entity_names).await?;
302
303 let mut relations = Vec::new();
304 let mut rel_rows = conn
305 .query(crate::constant::SQL_SELECT_ALL_RELATIONS, ())
306 .await?;
307 while let Some(row) = rel_rows.next().await? {
308 relations.push(crate::mcp::RelationInput {
309 from: row.get(0)?,
310 to: row.get(1)?,
311 relation_type: row.get(2)?,
312 });
313 }
314
315 Ok(crate::mcp::Graph {
316 entities,
317 relations,
318 })
319}
320
321pub async fn mcp_search_nodes(conn: &Connection, query: &str) -> Result<crate::mcp::Graph> {
322 mcp_search_nodes_with_limit(conn, query, DEFAULT_SEARCH_LIMIT).await
323}
324
325pub async fn mcp_search_nodes_with_limit(
326 conn: &Connection,
327 query: &str,
328 limit: usize,
329) -> Result<crate::mcp::Graph> {
330 let limit = limit.max(1);
331 let mut entity_names: Vec<String> = Vec::new();
332
333 let fts_hits: Vec<String> = async {
337 let fts_fetch_limit = limit.saturating_mul(8).max(limit) as i64;
338 let mut rows = conn
339 .query(
340 crate::constant::SQL_SEARCH_OBSERVATIONS_FTS,
341 libsql::params![query, fts_fetch_limit],
342 )
343 .await?;
344 let mut names = Vec::new();
345 while let Some(row) = rows.next().await? {
346 names.push(row.get::<String>(0)?);
347 }
348 Ok::<Vec<String>, anyhow::Error>(names)
349 }
350 .await
351 .unwrap_or_default();
352 for name in fts_hits {
353 if !entity_names.contains(&name) {
354 entity_names.push(name);
355 if entity_names.len() >= limit {
356 break;
357 }
358 }
359 }
360
361 let pattern = format!("%{}%", query);
364 let mut rows = conn
365 .query(
366 crate::constant::SQL_SEARCH_ENTITIES_LIKE,
367 libsql::params![pattern, limit as i64],
368 )
369 .await?;
370 while let Some(row) = rows.next().await? {
371 let name: String = row.get(0)?;
372 if !entity_names.contains(&name) {
373 entity_names.push(name);
374 if entity_names.len() >= limit {
375 break;
376 }
377 }
378 }
379
380 let relations = load_relations(conn, &entity_names).await?;
382 let mut all_entity_names = entity_names.clone();
383 for rel in &relations {
384 if !all_entity_names.contains(&rel.from) {
385 all_entity_names.push(rel.from.clone());
386 }
387 if !all_entity_names.contains(&rel.to) {
388 all_entity_names.push(rel.to.clone());
389 }
390 }
391
392 let entities = load_entities_lazy(conn, &all_entity_names).await?;
393
394 Ok(crate::mcp::Graph {
395 entities,
396 relations,
397 })
398}
399
400pub async fn mcp_open_nodes(conn: &Connection, names: Vec<String>) -> Result<crate::mcp::Graph> {
401 let normalized_names: Vec<String> = names
402 .into_iter()
403 .map(|n| crate::normalize::normalize_key(&n))
404 .collect();
405 let entities = load_entities_eager(conn, &normalized_names).await?;
406 let relations = load_relations(conn, &normalized_names).await?;
407
408 Ok(crate::mcp::Graph {
409 entities,
410 relations,
411 })
412}
413
414async fn load_relations(
415 conn: &Connection,
416 names: &[String],
417) -> Result<Vec<crate::mcp::RelationInput>> {
418 let mut relations = Vec::new();
419 if names.is_empty() {
420 return Ok(relations);
421 }
422
423 for chunk in names.chunks(400) {
424 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
425 let sql = crate::constant::SQL_SELECT_RELATIONS_IN_TEMPLATE.replace("{0}", &placeholders);
426
427 let mut params = Vec::new();
428 for name in chunk {
433 params.push(libsql::Value::from(name.clone()));
434 }
435 for name in chunk {
436 params.push(libsql::Value::from(name.clone()));
437 }
438
439 let mut rel_rows = conn.query(&sql, params).await?;
440 while let Some(row) = rel_rows.next().await? {
441 relations.push(crate::mcp::RelationInput {
442 from: row.get(0)?,
443 to: row.get(1)?,
444 relation_type: row.get(2)?,
445 });
446 }
447 }
448
449 Ok(relations)
450}
451
452async fn load_entities_lazy(
453 conn: &Connection,
454 names: &[String],
455) -> Result<Vec<crate::mcp::EntityOutput>> {
456 if names.is_empty() {
457 return Ok(Vec::new());
458 }
459
460 let mut entity_types = HashMap::new();
461 let mut obs_counts: HashMap<String, usize> = HashMap::new();
462 let truths = select_truths(conn, names).await?;
463
464 for chunk in names.chunks(500) {
465 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
466
467 let entity_sql =
469 crate::constant::SQL_SELECT_ENTITIES_IN_TEMPLATE.replace("{}", &placeholders);
470 let params = chunk
471 .iter()
472 .cloned()
473 .map(libsql::Value::from)
474 .collect::<Vec<_>>();
475
476 let mut rows = conn.query(&entity_sql, params.clone()).await?;
477 while let Some(row) = rows.next().await? {
478 entity_types.insert(row.get::<String>(0)?, row.get::<String>(1)?);
479 }
480
481 let obs_count_sql = format!(
483 "SELECT entity_name, COUNT(*) FROM mcp_observations WHERE entity_name IN ({}) GROUP BY entity_name",
484 placeholders
485 );
486 let mut rows = conn.query(&obs_count_sql, params).await?;
487 while let Some(row) = rows.next().await? {
488 obs_counts.insert(row.get::<String>(0)?, row.get::<i64>(1)? as usize);
489 }
490 }
491
492 let mut entities = Vec::new();
493 for name in names {
494 if let Some(entity_type) = entity_types.get(name) {
495 let entity_truths = truths.get(name).cloned().unwrap_or_default();
496 let count = obs_counts.get(name).cloned().unwrap_or(0);
497 entities.push(crate::mcp::EntityOutput {
498 name: name.clone(),
499 entity_type: entity_type.clone(),
500 observations: Vec::new(),
501 truths: entity_truths,
502 observation_count: count,
503 body: None,
504 });
505 }
506 }
507 Ok(entities)
508}
509
510async fn load_entities_eager(
511 conn: &Connection,
512 names: &[String],
513) -> Result<Vec<crate::mcp::EntityOutput>> {
514 if names.is_empty() {
515 return Ok(Vec::new());
516 }
517
518 let mut entity_types = HashMap::new();
519 let mut observations: HashMap<String, Vec<String>> = HashMap::new();
520 let mut skill_bodies: HashMap<String, String> = HashMap::new();
521 let truths = select_truths(conn, names).await?;
522
523 for chunk in names.chunks(500) {
524 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
525
526 let entity_sql =
528 crate::constant::SQL_SELECT_ENTITIES_IN_TEMPLATE.replace("{}", &placeholders);
529 let params = chunk
530 .iter()
531 .cloned()
532 .map(libsql::Value::from)
533 .collect::<Vec<_>>();
534
535 let mut rows = conn.query(&entity_sql, params.clone()).await?;
536 while let Some(row) = rows.next().await? {
537 entity_types.insert(row.get::<String>(0)?, row.get::<String>(1)?);
538 }
539
540 let obs_sql =
542 crate::constant::SQL_SELECT_OBSERVATIONS_IN_TEMPLATE.replace("{}", &placeholders);
543
544 let mut rows = conn.query(&obs_sql, params.clone()).await?;
545 while let Some(row) = rows.next().await? {
546 observations
547 .entry(row.get::<String>(0)?)
548 .or_default()
549 .push(row.get::<String>(1)?);
550 }
551
552 let skill_sql =
554 crate::constant::SQL_SELECT_SKILL_BODIES_IN_TEMPLATE.replace("{}", &placeholders);
555 let mut rows = conn.query(&skill_sql, params).await?;
556 while let Some(row) = rows.next().await? {
557 skill_bodies.insert(row.get::<String>(0)?, row.get::<String>(1)?);
558 }
559 }
560
561 let mut entities = Vec::new();
562 for name in names {
563 if let Some(entity_type) = entity_types.get(name) {
564 let entity_truths = truths.get(name).cloned().unwrap_or_default();
565 let entity_obs = observations.remove(name).unwrap_or_default();
566 let count = entity_obs.len();
567 let body = skill_bodies.get(name).cloned();
568 entities.push(crate::mcp::EntityOutput {
569 name: name.clone(),
570 entity_type: entity_type.clone(),
571 observations: entity_obs,
572 truths: entity_truths,
573 observation_count: count,
574 body,
575 });
576 }
577 }
578 Ok(entities)
579}
580
581pub async fn mcp_stats(conn: &Connection) -> Result<(usize, usize, usize)> {
582 let mut rows = conn.query(crate::constant::SQL_COUNT_ENTITIES, ()).await?;
583 let entities_count: i64 = if let Some(row) = rows.next().await? {
584 row.get(0)?
585 } else {
586 0
587 };
588
589 let mut rows = conn.query(crate::constant::SQL_COUNT_RELATIONS, ()).await?;
590 let relations_count: i64 = if let Some(row) = rows.next().await? {
591 row.get(0)?
592 } else {
593 0
594 };
595
596 let mut rows = conn
597 .query(crate::constant::SQL_COUNT_OBSERVATIONS, ())
598 .await?;
599 let observations_count: i64 = if let Some(row) = rows.next().await? {
600 row.get(0)?
601 } else {
602 0
603 };
604
605 Ok((
606 entities_count as usize,
607 relations_count as usize,
608 observations_count as usize,
609 ))
610}
611
612pub async fn mcp_reset(conn: &Connection) -> Result<()> {
613 conn.execute(crate::constant::SQL_DELETE_ALL_RELATIONS, ())
614 .await?;
615 conn.execute(crate::constant::SQL_DELETE_ALL_OBSERVATIONS, ())
616 .await?;
617 conn.execute(crate::constant::SQL_DELETE_ALL_ENTITIES, ())
618 .await?;
619 Ok(())
620}
621
622pub async fn truth_upsert(conn: &Connection, entity: &str, key: &str, value: &str) -> Result<()> {
623 let norm_entity = crate::normalize::normalize_key(entity);
624 conn.execute(
625 crate::constant::SQL_UPSERT_TRUTH,
626 libsql::params![norm_entity, key, value],
627 )
628 .await?;
629 Ok(())
630}
631
632pub async fn truth_delete(conn: &Connection, entity: &str, key: &str) -> Result<()> {
633 let norm_entity = crate::normalize::normalize_key(entity);
634 conn.execute(
635 crate::constant::SQL_DELETE_TRUTH,
636 libsql::params![norm_entity, key],
637 )
638 .await?;
639 Ok(())
640}
641
642pub async fn select_truths(
643 conn: &Connection,
644 names: &[impl AsRef<str>],
645) -> Result<HashMap<String, std::collections::BTreeMap<String, String>>> {
646 let mut results = HashMap::new();
647 if names.is_empty() {
648 return Ok(results);
649 }
650
651 let normalized_names: Vec<String> = names
652 .iter()
653 .map(|n| crate::normalize::normalize_key(n.as_ref()))
654 .filter(|n| !n.is_empty())
655 .collect();
656
657 if normalized_names.is_empty() {
658 return Ok(results);
659 }
660
661 for chunk in normalized_names.chunks(500) {
662 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
663 let sql = crate::constant::SQL_SELECT_TRUTHS_FOR_ENTITIES.replace("{}", &placeholders);
664 let params = chunk
665 .iter()
666 .cloned()
667 .map(libsql::Value::from)
668 .collect::<Vec<_>>();
669
670 let mut rows = conn.query(&sql, params).await?;
671 while let Some(row) = rows.next().await? {
672 let entity_name: String = row.get(0)?;
673 let key: String = row.get(1)?;
674 let value: String = row.get(2)?;
675 results
676 .entry(entity_name)
677 .or_insert_with(std::collections::BTreeMap::new)
678 .insert(key, value);
679 }
680 }
681
682 Ok(results)
683}
684
685#[derive(Debug, Clone, PartialEq, Eq)]
686pub struct SkillRow {
687 pub entity_name: String,
688 pub description: String,
689 pub version: String,
690 pub source: String,
691 pub installed_at: String,
692}
693
694pub async fn skill_upsert(
695 conn: &Connection,
696 entity: &str,
697 body: &str,
698 source: &str,
699 version: &str,
700) -> Result<()> {
701 let norm_entity = crate::normalize::normalize_key(entity);
702 conn.execute(
703 crate::constant::SQL_UPSERT_SKILL,
704 libsql::params![norm_entity, body, source, version],
705 )
706 .await?;
707 Ok(())
708}
709
710pub async fn skill_body(conn: &Connection, entity: &str) -> Result<Option<String>> {
711 let norm_entity = crate::normalize::normalize_key(entity);
712 let mut rows = conn
713 .query(
714 crate::constant::SQL_SELECT_SKILL_BODY,
715 libsql::params![norm_entity],
716 )
717 .await?;
718 if let Some(row) = rows.next().await? {
719 let body: String = row.get(0)?;
720 Ok(Some(body))
721 } else {
722 Ok(None)
723 }
724}
725
726pub async fn list_skills(conn: &Connection) -> Result<Vec<SkillRow>> {
727 let mut rows = conn.query(crate::constant::SQL_LIST_SKILLS, ()).await?;
728 let mut results = Vec::new();
729 while let Some(row) = rows.next().await? {
730 results.push(SkillRow {
731 entity_name: row.get(0)?,
732 description: row.get(1)?,
733 version: row.get(2)?,
734 source: row.get(3)?,
735 installed_at: row.get(4)?,
736 });
737 }
738 Ok(results)
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use tempfile::tempdir;
745
746 #[tokio::test]
747 async fn test_init_creates_all_tables() {
748 let dir = tempdir().unwrap();
749 let db_path = dir.path().join("test.db");
750 unsafe {
751 std::env::set_var(ENV_DATABASE_URL, db_path.to_str().unwrap());
752 }
753 let (_db, conn) = init_db().await.unwrap();
754
755 let mut rows = conn
757 .query("SELECT name FROM sqlite_master WHERE name='topics_fts'", ())
758 .await
759 .unwrap();
760 let row = rows.next().await.unwrap();
761 assert!(row.is_some(), "topics_fts table missing");
762 }
763
764 #[tokio::test]
765 async fn test_fts_search_finds_topic() {
766 let dir = tempdir().unwrap();
767 let db_path = dir.path().join("test.db");
768 unsafe {
769 std::env::set_var(ENV_DATABASE_URL, db_path.to_str().unwrap());
770 }
771 let (_db, conn) = init_db().await.unwrap();
772
773 conn.execute(
774 "INSERT INTO topics (id, title, file_path) VALUES ('rust-pin', 'Rust Pinning', '.miku/topics/rust-pinning.md')",
775 (),
776 ).await.unwrap();
777 conn.execute(
778 "INSERT INTO topics_fts (rowid, title, body) VALUES ((SELECT rowid FROM topics WHERE id='rust-pin'), 'Rust Pinning', 'pinning is a mechanism...')",
779 (),
780 ).await.unwrap();
781
782 let results = search_fts(&conn, "pinning", 5).await.unwrap();
783 assert_eq!(results.len(), 1);
784 assert_eq!(results[0].1, "Rust Pinning");
785 }
786
787 #[tokio::test]
788 async fn test_upsert_topic_preserves_created_at() {
789 let dir = tempdir().unwrap();
790 let db_path = dir.path().join("test.db");
791 unsafe {
792 std::env::set_var(ENV_DATABASE_URL, db_path.to_str().unwrap());
793 }
794 let (_db, conn) = init_db().await.unwrap();
795
796 conn.execute(
799 "INSERT INTO topics (id, title, file_path, body, created_at) \
800 VALUES ('t1', 'Old Title', '/old', 'old body', '2000-01-01 00:00:00')",
801 (),
802 )
803 .await
804 .unwrap();
805
806 upsert_topic(&conn, "t1", "New Title", "/new", "new body")
808 .await
809 .unwrap();
810
811 let mut rows = conn
812 .query(
813 "SELECT title, body, created_at FROM topics WHERE id = 't1'",
814 (),
815 )
816 .await
817 .unwrap();
818 let row = rows.next().await.unwrap().expect("row should exist");
819 let title: String = row.get(0).unwrap();
820 let body: String = row.get(1).unwrap();
821 let created_at: String = row.get(2).unwrap();
822
823 assert_eq!(title, "New Title");
825 assert_eq!(body, "new body");
826 assert_eq!(created_at, "2000-01-01 00:00:00");
828 }
829
830 async fn seed_entity(conn: &Connection, name: &str, entity_type: &str, obs: &[&str]) {
831 mcp_create_entities(
832 conn,
833 vec![crate::mcp::EntityInput {
834 name: name.to_string(),
835 entity_type: entity_type.to_string(),
836 observations: obs.iter().map(|s| s.to_string()).collect(),
837 }],
838 )
839 .await
840 .unwrap();
841 }
842
843 #[tokio::test]
844 async fn test_search_nodes_stemming() {
845 let dir = tempdir().unwrap();
846 unsafe {
847 std::env::set_var(
848 ENV_DATABASE_URL,
849 dir.path().join("test.db").to_str().unwrap(),
850 );
851 }
852 let (_db, conn) = init_db().await.unwrap();
853
854 seed_entity(
855 &conn,
856 "async-patterns",
857 "concept",
858 &["running async tasks efficiently"],
859 )
860 .await;
861
862 let graph = mcp_search_nodes(&conn, "run").await.unwrap();
864 assert_eq!(graph.entities.len(), 1);
865 assert_eq!(graph.entities[0].name, "async-patterns");
866 }
867
868 #[tokio::test]
869 async fn test_search_nodes_entity_name_fallback() {
870 let dir = tempdir().unwrap();
871 unsafe {
872 std::env::set_var(
873 ENV_DATABASE_URL,
874 dir.path().join("test.db").to_str().unwrap(),
875 );
876 }
877 let (_db, conn) = init_db().await.unwrap();
878
879 seed_entity(&conn, "user-preferences", "preference", &[]).await;
881
882 let graph = mcp_search_nodes(&conn, "user-preferences").await.unwrap();
883 assert_eq!(graph.entities.len(), 1);
884 assert_eq!(graph.entities[0].name, "user-preferences");
885 }
886
887 #[tokio::test]
888 async fn test_search_nodes_bm25_ordering() {
889 let dir = tempdir().unwrap();
890 unsafe {
891 std::env::set_var(
892 ENV_DATABASE_URL,
893 dir.path().join("test.db").to_str().unwrap(),
894 );
895 }
896 let (_db, conn) = init_db().await.unwrap();
897
898 seed_entity(&conn, "alpha", "project", &["async tokio runtime patterns"]).await;
900 seed_entity(&conn, "beta", "project", &["tokio scheduler"]).await;
901
902 let graph = mcp_search_nodes(&conn, "async tokio").await.unwrap();
903 assert!(!graph.entities.is_empty());
904 assert_eq!(graph.entities[0].name, "alpha");
905 }
906
907 #[tokio::test]
908 async fn test_search_nodes_invalid_fts_syntax_no_panic() {
909 let dir = tempdir().unwrap();
910 unsafe {
911 std::env::set_var(
912 ENV_DATABASE_URL,
913 dir.path().join("test.db").to_str().unwrap(),
914 );
915 }
916 let (_db, conn) = init_db().await.unwrap();
917
918 let result = mcp_search_nodes(&conn, "AND AND").await;
920 assert!(result.is_ok());
921 }
922
923 #[tokio::test]
924 async fn test_search_nodes_default_limit_and_explicit_limit() {
925 let dir = tempdir().unwrap();
926 unsafe {
927 std::env::set_var(
928 ENV_DATABASE_URL,
929 dir.path().join("test.db").to_str().unwrap(),
930 );
931 }
932 let (_db, conn) = init_db().await.unwrap();
933
934 for i in 0..(DEFAULT_SEARCH_LIMIT + 10) {
935 seed_entity(&conn, &format!("entity-{i:03}"), "project", &["commonterm"]).await;
936 }
937
938 let default_graph = mcp_search_nodes(&conn, "commonterm").await.unwrap();
939 assert_eq!(default_graph.entities.len(), DEFAULT_SEARCH_LIMIT);
940
941 let explicit_graph = mcp_search_nodes_with_limit(&conn, "commonterm", 7)
942 .await
943 .unwrap();
944 assert_eq!(explicit_graph.entities.len(), 7);
945 }
946
947 #[tokio::test]
948 async fn test_mcp_stats_and_reset() {
949 let dir = tempdir().unwrap();
950 unsafe {
951 std::env::set_var(
952 ENV_DATABASE_URL,
953 dir.path().join("test.db").to_str().unwrap(),
954 );
955 }
956 let (_db, conn) = init_db().await.unwrap();
957
958 let stats = mcp_stats(&conn).await.unwrap();
960 assert_eq!(stats, (0, 0, 0));
961
962 seed_entity(&conn, "entity1", "project", &["obs1", "obs2"]).await;
964 seed_entity(&conn, "entity2", "project", &["obs3"]).await;
965 mcp_create_relations(
966 &conn,
967 vec![crate::mcp::RelationInput {
968 from: "entity1".to_string(),
969 to: "entity2".to_string(),
970 relation_type: "related".to_string(),
971 }],
972 )
973 .await
974 .unwrap();
975
976 let stats = mcp_stats(&conn).await.unwrap();
978 assert_eq!(stats, (2, 1, 3));
979
980 mcp_reset(&conn).await.unwrap();
982
983 let stats = mcp_stats(&conn).await.unwrap();
985 assert_eq!(stats, (0, 0, 0));
986 }
987
988 #[tokio::test]
989 async fn test_truth_upsert_twice_same_key() {
990 let dir = tempdir().unwrap();
991 unsafe {
992 std::env::set_var(
993 ENV_DATABASE_URL,
994 dir.path().join("test.db").to_str().unwrap(),
995 );
996 }
997 let (_db, conn) = init_db().await.unwrap();
998
999 seed_entity(&conn, "test-entity", "concept", &[]).await;
1000
1001 truth_upsert(&conn, "test-entity", "version", "1.0.0")
1002 .await
1003 .unwrap();
1004 truth_upsert(&conn, "test-entity", "version", "1.0.1")
1005 .await
1006 .unwrap();
1007
1008 let truths = select_truths(&conn, &["test-entity"]).await.unwrap();
1009 let entity_truths = truths.get("test-entity").expect("should have truths");
1010 assert_eq!(entity_truths.len(), 1);
1011 assert_eq!(entity_truths.get("version").unwrap(), "1.0.1");
1012 }
1013
1014 #[tokio::test]
1015 async fn test_truth_upsert_two_keys() {
1016 let dir = tempdir().unwrap();
1017 unsafe {
1018 std::env::set_var(
1019 ENV_DATABASE_URL,
1020 dir.path().join("test.db").to_str().unwrap(),
1021 );
1022 }
1023 let (_db, conn) = init_db().await.unwrap();
1024
1025 seed_entity(&conn, "test-entity", "concept", &[]).await;
1026
1027 truth_upsert(&conn, "test-entity", "version", "1.0.0")
1028 .await
1029 .unwrap();
1030 truth_upsert(&conn, "test-entity", "author", "Alice")
1031 .await
1032 .unwrap();
1033
1034 let truths = select_truths(&conn, &["test-entity"]).await.unwrap();
1035 let entity_truths = truths.get("test-entity").expect("should have truths");
1036 assert_eq!(entity_truths.len(), 2);
1037 assert_eq!(entity_truths.get("author").unwrap(), "Alice");
1038 assert_eq!(entity_truths.get("version").unwrap(), "1.0.0");
1039 }
1040
1041 #[tokio::test]
1042 async fn test_truth_delete() {
1043 let dir = tempdir().unwrap();
1044 unsafe {
1045 std::env::set_var(
1046 ENV_DATABASE_URL,
1047 dir.path().join("test.db").to_str().unwrap(),
1048 );
1049 }
1050 let (_db, conn) = init_db().await.unwrap();
1051
1052 seed_entity(&conn, "test-entity", "concept", &[]).await;
1053
1054 truth_upsert(&conn, "test-entity", "k1", "v1")
1055 .await
1056 .unwrap();
1057 truth_upsert(&conn, "test-entity", "k2", "v2")
1058 .await
1059 .unwrap();
1060
1061 truth_delete(&conn, "test-entity", "k1").await.unwrap();
1062
1063 let truths = select_truths(&conn, &["test-entity"]).await.unwrap();
1064 let entity_truths = truths.get("test-entity").expect("should have truths");
1065 assert_eq!(entity_truths.len(), 1);
1066 assert_eq!(entity_truths.get("k2").unwrap(), "v2");
1067 }
1068
1069 #[tokio::test]
1070 async fn test_delete_entities_cascades_truths() {
1071 let dir = tempdir().unwrap();
1072 unsafe {
1073 std::env::set_var(
1074 ENV_DATABASE_URL,
1075 dir.path().join("test.db").to_str().unwrap(),
1076 );
1077 }
1078 let (_db, conn) = init_db().await.unwrap();
1079
1080 seed_entity(&conn, "test-entity", "concept", &[]).await;
1081 truth_upsert(&conn, "test-entity", "k1", "v1")
1082 .await
1083 .unwrap();
1084
1085 mcp_delete_entities(&conn, vec!["test-entity".to_string()])
1086 .await
1087 .unwrap();
1088
1089 let mut rows = conn
1091 .query("SELECT COUNT(*) FROM mcp_truths", ())
1092 .await
1093 .unwrap();
1094 let count: i64 = rows.next().await.unwrap().unwrap().get(0).unwrap();
1095 assert_eq!(count, 0);
1096 }
1097
1098 #[tokio::test]
1099 async fn test_observation_limit_evicts_oldest() {
1100 let dir = tempdir().unwrap();
1101 unsafe {
1102 std::env::set_var(
1103 ENV_DATABASE_URL,
1104 dir.path().join("test.db").to_str().unwrap(),
1105 );
1106 }
1107 let (_db, conn) = init_db().await.unwrap();
1108
1109 seed_entity(&conn, "test-entity", "concept", &[]).await;
1110
1111 let inputs = vec![crate::mcp::ObservationInput {
1112 entity_name: "test-entity".to_string(),
1113 contents: vec![
1114 "obs1".to_string(),
1115 "obs2".to_string(),
1116 "obs3".to_string(),
1117 "obs4".to_string(),
1118 "obs5".to_string(),
1119 ],
1120 }];
1121 mcp_add_observations(&conn, inputs, 3).await.unwrap();
1122
1123 let graph = mcp_open_nodes(&conn, vec!["test-entity".to_string()])
1124 .await
1125 .unwrap();
1126 let entity = &graph.entities[0];
1127 let mut obs = entity.observations.clone();
1128 obs.sort();
1129 assert_eq!(
1130 obs,
1131 vec!["obs3".to_string(), "obs4".to_string(), "obs5".to_string(),]
1132 );
1133 }
1134
1135 #[tokio::test]
1136 async fn test_observation_limit_zero_is_unbounded() {
1137 let dir = tempdir().unwrap();
1138 unsafe {
1139 std::env::set_var(
1140 ENV_DATABASE_URL,
1141 dir.path().join("test.db").to_str().unwrap(),
1142 );
1143 }
1144 let (_db, conn) = init_db().await.unwrap();
1145
1146 seed_entity(&conn, "test-entity", "concept", &[]).await;
1147
1148 let inputs = vec![crate::mcp::ObservationInput {
1149 entity_name: "test-entity".to_string(),
1150 contents: vec![
1151 "obs1".to_string(),
1152 "obs2".to_string(),
1153 "obs3".to_string(),
1154 "obs4".to_string(),
1155 "obs5".to_string(),
1156 ],
1157 }];
1158 mcp_add_observations(&conn, inputs, 0).await.unwrap();
1159
1160 let graph = mcp_open_nodes(&conn, vec!["test-entity".to_string()])
1161 .await
1162 .unwrap();
1163 let entity = &graph.entities[0];
1164 assert_eq!(entity.observations.len(), 5);
1165 }
1166
1167 #[tokio::test]
1168 async fn test_lazy_read_graph_and_search_nodes() {
1169 let dir = tempdir().unwrap();
1170 unsafe {
1171 std::env::set_var(
1172 ENV_DATABASE_URL,
1173 dir.path().join("test.db").to_str().unwrap(),
1174 );
1175 }
1176 let (_db, conn) = init_db().await.unwrap();
1177
1178 seed_entity(&conn, "test-entity", "concept", &["obs1", "obs2"]).await;
1179 truth_upsert(&conn, "test-entity", "k1", "v1")
1180 .await
1181 .unwrap();
1182
1183 let graph_read = mcp_read_graph(&conn).await.unwrap();
1185 let entity_read = &graph_read.entities[0];
1186 assert!(entity_read.observations.is_empty());
1187 assert_eq!(entity_read.observation_count, 2);
1188 assert_eq!(entity_read.truths.len(), 1);
1189 assert_eq!(entity_read.truths.get("k1").unwrap(), "v1");
1190
1191 let graph_search = mcp_search_nodes(&conn, "test").await.unwrap();
1193 let entity_search = &graph_search.entities[0];
1194 assert!(entity_search.observations.is_empty());
1195 assert_eq!(entity_search.observation_count, 2);
1196 assert_eq!(entity_search.truths.len(), 1);
1197 assert_eq!(entity_search.truths.get("k1").unwrap(), "v1");
1198
1199 let graph_open = mcp_open_nodes(&conn, vec!["test-entity".to_string()])
1201 .await
1202 .unwrap();
1203 let entity_open = &graph_open.entities[0];
1204 assert_eq!(entity_open.observations.len(), 2);
1205 assert_eq!(entity_open.observation_count, 2);
1206 assert_eq!(entity_open.truths.len(), 1);
1207 assert_eq!(entity_open.truths.get("k1").unwrap(), "v1");
1208 }
1209
1210 #[tokio::test]
1211 async fn test_skill_storage_and_mcp_open_nodes() {
1212 let dir = tempdir().unwrap();
1213 unsafe {
1214 std::env::set_var(
1215 ENV_DATABASE_URL,
1216 dir.path().join("test.db").to_str().unwrap(),
1217 );
1218 }
1219 let (_db, conn) = init_db().await.unwrap();
1220
1221 seed_entity(&conn, "skill:test-skill", "skill", &[]).await;
1222 truth_upsert(&conn, "skill:test-skill", "description", "my test skill")
1223 .await
1224 .unwrap();
1225
1226 skill_upsert(
1228 &conn,
1229 "skill:test-skill",
1230 "body content 1",
1231 "source-1",
1232 "1.0.0",
1233 )
1234 .await
1235 .unwrap();
1236
1237 let graph = mcp_open_nodes(&conn, vec!["skill:test-skill".to_string()])
1239 .await
1240 .unwrap();
1241 let entity = &graph.entities[0];
1242 assert_eq!(entity.body.as_deref(), Some("body content 1"));
1243
1244 let graph_read = mcp_read_graph(&conn).await.unwrap();
1246 assert!(graph_read.entities[0].body.is_none());
1247
1248 let graph_search = mcp_search_nodes(&conn, "skill").await.unwrap();
1249 assert!(graph_search.entities[0].body.is_none());
1250
1251 skill_upsert(
1253 &conn,
1254 "skill:test-skill",
1255 "body content 2",
1256 "source-1",
1257 "1.0.1",
1258 )
1259 .await
1260 .unwrap();
1261 let graph_2 = mcp_open_nodes(&conn, vec!["skill:test-skill".to_string()])
1262 .await
1263 .unwrap();
1264 assert_eq!(graph_2.entities[0].body.as_deref(), Some("body content 2"));
1265
1266 let skills = list_skills(&conn).await.unwrap();
1268 assert_eq!(skills.len(), 1);
1269 assert_eq!(skills[0].entity_name, "skill:test-skill");
1270 assert_eq!(skills[0].description, "my test skill");
1271 assert_eq!(skills[0].version, "1.0.1");
1272 assert_eq!(skills[0].source, "source-1");
1273
1274 mcp_delete_entities(&conn, vec!["skill:test-skill".to_string()])
1276 .await
1277 .unwrap();
1278 let body_after = skill_body(&conn, "skill:test-skill").await.unwrap();
1279 assert!(body_after.is_none());
1280
1281 let count_skills: i64 = conn
1282 .query("SELECT COUNT(*) FROM mcp_skills", ())
1283 .await
1284 .unwrap()
1285 .next()
1286 .await
1287 .unwrap()
1288 .unwrap()
1289 .get(0)
1290 .unwrap();
1291 assert_eq!(count_skills, 0);
1292 }
1293}