1use crate::error::KbError;
41use std::cmp::Ordering;
42use std::collections::{BTreeMap, HashSet};
43
44use rusqlite::{Connection, OptionalExtension, params};
45
46use crate::ast::*;
47use crate::embedding;
48use crate::sexp;
49
50pub const CURRENT_SCHEMA_VERSION: u32 = 2;
58
59const NODES_TABLE: &str = concat!(
60 "CREATE TABLE IF NOT EXISTS nodes (",
61 " id TEXT PRIMARY KEY NOT NULL,",
62 " title TEXT NOT NULL,",
63 " ast_blob TEXT NOT NULL,",
64 " created_at TEXT NOT NULL,",
65 " updated_at TEXT NOT NULL",
66 ")"
67);
68
69const NODE_TAGS_TABLE: &str = concat!(
70 "CREATE TABLE IF NOT EXISTS node_tags (",
71 " node_id TEXT NOT NULL,",
72 " tag TEXT NOT NULL,",
73 " PRIMARY KEY (node_id, tag),",
74 " FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE",
75 ")"
76);
77
78const LINKS_TABLE: &str = concat!(
94 "CREATE TABLE IF NOT EXISTS links (",
95 " source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,",
96 " link_type TEXT NOT NULL,",
97 " target_id TEXT REFERENCES nodes(id) ON DELETE SET NULL,",
98 " target_slug TEXT,",
99 " PRIMARY KEY (source_id, link_type, target_id, target_slug),",
100 " CHECK (",
101 " (link_type = 'id' AND target_id IS NOT NULL AND target_slug IS NULL)",
102 " OR (link_type = 'name' AND target_slug IS NOT NULL)",
103 " )",
104 ")"
105);
106
107const AUDIT_LOG_TABLE: &str = concat!(
108 "CREATE TABLE IF NOT EXISTS audit_log (",
109 " id INTEGER PRIMARY KEY AUTOINCREMENT,",
110 " node_id TEXT NOT NULL,",
111 " operation TEXT NOT NULL,",
112 " old_blob TEXT,",
113 " new_blob TEXT,",
114 " timestamp TEXT NOT NULL",
115 ")"
116);
117
118const NODES_UPDATED_AT_INDEX: &str =
119 "CREATE INDEX IF NOT EXISTS nodes_updated_at_idx ON nodes (updated_at DESC)";
120
121const NODE_TAGS_TAG_INDEX: &str = "CREATE INDEX IF NOT EXISTS node_tags_tag_idx ON node_tags (tag)";
122
123const LINKS_TARGET_INDEX: &str = "CREATE INDEX IF NOT EXISTS links_target_idx ON links (target_id)";
124
125const LINKS_TARGET_SLUG_INDEX: &str =
126 "CREATE INDEX IF NOT EXISTS links_target_slug_idx ON links (target_slug)";
127
128const AUDIT_LOG_NODE_INDEX: &str =
129 "CREATE INDEX IF NOT EXISTS audit_log_node_idx ON audit_log (node_id)";
130
131const AUDIT_LOG_TIMESTAMP_INDEX: &str =
132 "CREATE INDEX IF NOT EXISTS audit_log_ts_idx ON audit_log (timestamp)";
133
134const NODES_FTS_TABLE: &str = concat!(
135 "CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(",
136 " node_id UNINDEXED,",
137 " title,",
138 " body",
139 ")"
140);
141
142const EMBEDDINGS_TABLE: &str = concat!(
143 "CREATE TABLE IF NOT EXISTS embeddings (",
144 " node_id TEXT NOT NULL,",
145 " model TEXT NOT NULL,",
146 " embedding BLOB NOT NULL,",
147 " PRIMARY KEY (node_id, model),",
148 " FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE",
149 ")"
150);
151
152const EMBEDDINGS_MODEL_INDEX: &str =
153 "CREATE INDEX IF NOT EXISTS embeddings_model_idx ON embeddings (model)";
154
155pub fn open_db(path: &str) -> Result<Connection, KbError> {
161 if let Some(parent) = std::path::Path::new(path).parent() {
165 if !parent.as_os_str().is_empty() {
166 let _ = std::fs::create_dir_all(parent);
167 }
168 }
169 let conn = Connection::open(path)?;
170 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
171 Ok(conn)
172}
173
174#[must_use]
178pub fn default_db_path() -> std::path::PathBuf {
179 dirs::home_dir().map_or_else(
180 || std::path::PathBuf::from("kb.db"),
181 |home| home.join(".local/share/kb/kb.db"),
182 )
183}
184
185pub fn init_db(conn: &Connection) -> Result<(), KbError> {
197 conn.execute_batch("PRAGMA journal_mode = WAL;")?;
198 let prior_version: u32 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
199 conn.execute(NODES_TABLE, [])?;
200 conn.execute(NODE_TAGS_TABLE, [])?;
201 migrate_links_table(conn)?;
202 conn.execute(AUDIT_LOG_TABLE, [])?;
203 conn.execute(NODES_UPDATED_AT_INDEX, [])?;
204 conn.execute(NODE_TAGS_TAG_INDEX, [])?;
205 conn.execute(LINKS_TARGET_INDEX, [])?;
206 conn.execute(LINKS_TARGET_SLUG_INDEX, [])?;
207 conn.execute(AUDIT_LOG_NODE_INDEX, [])?;
208 conn.execute(AUDIT_LOG_TIMESTAMP_INDEX, [])?;
209 conn.execute_batch(NODES_FTS_TABLE)?;
210 conn.execute(EMBEDDINGS_TABLE, [])?;
211 conn.execute(EMBEDDINGS_MODEL_INDEX, [])?;
212 if prior_version < 2 {
213 migrate_ast_blob_encoding(conn)?;
214 }
215 conn.execute_batch(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION};"))?;
216 Ok(())
217}
218
219fn migrate_ast_blob_encoding(conn: &Connection) -> Result<(), KbError> {
227 let tx = conn.unchecked_transaction()?;
228 let rows: Vec<(String, String)> = {
229 let mut stmt = tx.prepare("SELECT id, ast_blob FROM nodes")?;
230 let mapped =
231 stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
232 mapped.collect::<Result<_, _>>()?
233 };
234 {
235 let mut update = tx.prepare("UPDATE nodes SET ast_blob = ?1 WHERE id = ?2")?;
236 for (id, blob) in &rows {
237 let doc = decode_blob(id, blob)?;
238 let encoded = sexp::encode_document(&doc);
239 if encoded != *blob {
240 update.execute(params![encoded, id])?;
241 }
242 }
243 }
244 tx.commit()?;
245 Ok(())
246}
247
248fn migrate_links_table(conn: &Connection) -> Result<(), rusqlite::Error> {
267 let links_exists: bool = table_exists(conn, "links")?;
268 let name_links_exists: bool = table_exists(conn, "name_links")?;
269 let links_is_legacy: bool = links_exists && !column_exists(conn, "links", "target_slug")?;
270
271 if !links_is_legacy && !name_links_exists {
272 conn.execute(LINKS_TABLE, [])?;
274 return Ok(());
275 }
276
277 let mut legacy_id_links: Vec<(String, String)> = Vec::new();
279 if links_is_legacy {
280 let mut stmt = conn.prepare("SELECT source_id, target_id FROM links")?;
281 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
282 for row in rows {
283 legacy_id_links.push(row?);
284 }
285 }
286 let mut legacy_name_links: Vec<(String, String, Option<String>)> = Vec::new();
287 if name_links_exists {
288 let mut stmt = conn.prepare("SELECT src_id, dst_slug, dst_id FROM name_links")?;
289 let rows = stmt.query_map([], |r| {
290 Ok((
291 r.get::<_, String>(0)?,
292 r.get::<_, String>(1)?,
293 r.get::<_, Option<String>>(2)?,
294 ))
295 })?;
296 for row in rows {
297 legacy_name_links.push(row?);
298 }
299 }
300
301 let tx = conn.unchecked_transaction()?;
302 if links_is_legacy {
303 tx.execute("DROP TABLE links", [])?;
304 }
305 if name_links_exists {
306 tx.execute("DROP TABLE name_links", [])?;
307 }
308 tx.execute(LINKS_TABLE, [])?;
309 {
310 let mut ins_id = tx.prepare(
311 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
312 VALUES (?1, 'id', ?2, NULL)",
313 )?;
314 for (src, tgt) in &legacy_id_links {
315 ins_id.execute(params![src, tgt])?;
316 }
317 let mut ins_name = tx.prepare(
318 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
319 VALUES (?1, 'name', ?2, ?3)",
320 )?;
321 for (src, slug, dst) in &legacy_name_links {
322 ins_name.execute(params![src, dst, slug])?;
323 }
324 }
325 tx.commit()?;
326 Ok(())
327}
328
329fn table_exists(conn: &Connection, name: &str) -> Result<bool, rusqlite::Error> {
330 let n: i64 = conn.query_row(
331 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
332 params![name],
333 |r| r.get(0),
334 )?;
335 Ok(n > 0)
336}
337
338fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, rusqlite::Error> {
339 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
340 let rows = stmt.query_map([], |r| r.get::<_, String>(1))?;
341 for row in rows {
342 if row? == column {
343 return Ok(true);
344 }
345 }
346 Ok(false)
347}
348
349#[derive(Debug, Clone)]
353pub struct NodeRow {
354 pub id: NodeId,
355 pub title: Title,
356 pub ast_blob: String,
357 pub created_at: String,
358 pub updated_at: String,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
370#[serde(rename_all = "lowercase")]
371pub enum LinkType {
372 Id,
374 Name,
376}
377
378impl LinkType {
379 #[must_use]
381 pub const fn as_str(self) -> &'static str {
382 match self {
383 Self::Id => "id",
384 Self::Name => "name",
385 }
386 }
387}
388
389impl std::fmt::Display for LinkType {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 f.write_str(self.as_str())
392 }
393}
394
395impl rusqlite::types::FromSql for LinkType {
396 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
397 match value.as_str()? {
398 "id" => Ok(Self::Id),
399 "name" => Ok(Self::Name),
400 _ => Err(rusqlite::types::FromSqlError::InvalidType),
403 }
404 }
405}
406
407impl rusqlite::types::FromSql for NodeId {
408 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
409 value.as_str().map(|s| Self(s.to_owned()))
410 }
411}
412
413impl rusqlite::types::FromSql for Title {
414 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
415 value.as_str().map(|s| Self(s.to_owned()))
416 }
417}
418
419impl rusqlite::types::FromSql for Tag {
420 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
421 value.as_str().map(|s| Self(s.to_owned()))
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct LinkRow {
427 pub source_id: String,
428 pub link_type: LinkType,
429 pub target_id: Option<String>,
430 pub target_slug: Option<String>,
431}
432
433#[derive(Debug, Clone)]
435pub struct AuditRow {
436 pub id: i64,
437 pub node_id: String,
438 pub operation: String,
439 pub old_blob: Option<String>,
440 pub new_blob: Option<String>,
441 pub timestamp: String,
442}
443
444#[derive(Debug, Clone, Default, PartialEq, Eq)]
447pub struct Neighborhood {
448 pub outgoing: Vec<(NodeId, LinkType)>,
449 pub incoming: Vec<(NodeId, LinkType)>,
450}
451
452fn now_iso8601() -> String {
453 chrono::Utc::now()
454 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
455 .to_string()
456}
457
458fn write_embedding_row(
464 conn: &Connection,
465 node_id: &str,
466 embedding: Option<Vec<f32>>,
467 model: Option<&str>,
468) {
469 let (Some(vec), Some(model)) = (embedding, model) else {
470 return;
471 };
472 let blob = embedding::encode_embedding(&vec);
473 if let Err(e) = conn.execute(
474 "INSERT OR REPLACE INTO embeddings (node_id, model, embedding) VALUES (?1, ?2, ?3)",
475 params![node_id, model, blob],
476 ) {
477 tracing::error!(node_id, error = %e, "kb embed write failed");
478 }
479}
480
481pub fn insert_node(
491 conn: &Connection,
492 node_id: &str,
493 document: &Document,
494) -> Result<(), rusqlite::Error> {
495 insert_node_with(conn, node_id, document, None, None)
496}
497
498pub fn insert_node_with(
515 conn: &Connection,
516 node_id: &str,
517 document: &Document,
518 embedding: Option<Vec<f32>>,
519 model: Option<&str>,
520) -> Result<(), rusqlite::Error> {
521 let blob = sexp::encode_document(document);
522 let title = extract_title(document);
523 let tags = extract_tags(document);
524 let body_text = extract_body_text(document);
525 let now = now_iso8601();
526
527 let tx = conn.unchecked_transaction()?;
528
529 tx.execute(
530 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
531 params![node_id, title, blob, now, now],
532 )?;
533 for tag in &tags {
534 tx.execute(
535 "INSERT INTO node_tags (node_id, tag) VALUES (?1, ?2)",
536 params![node_id, tag.0.as_str()],
537 )?;
538 }
539 tx.execute(
540 "INSERT INTO nodes_fts (node_id, title, body) VALUES (?1, ?2, ?3)",
541 params![node_id, title, body_text],
542 )?;
543 tx.execute(
544 "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'insert', NULL, ?2, ?3)",
545 params![node_id, blob, now],
546 )?;
547
548 relink_one_inner(&tx, node_id, document)?;
549 if let Some(slug) = extract_name_slug(document) {
552 tx.execute(
553 "UPDATE links SET target_id = ?1 \
554 WHERE link_type = 'name' AND target_slug = ?2 AND target_id IS NULL",
555 params![node_id, slug],
556 )?;
557 }
558
559 tx.commit()?;
560
561 write_embedding_row(conn, node_id, embedding, model);
562
563 Ok(())
564}
565
566pub fn get_node(conn: &Connection, id: &str) -> Result<Option<Document>, KbError> {
573 let mut stmt = conn.prepare("SELECT ast_blob FROM nodes WHERE id = ?1")?;
574 let mut rows = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
575 match rows.next() {
576 Some(Ok(blob)) => {
577 let doc = decode_blob(id, &blob)?;
578 Ok(Some(doc))
579 }
580 Some(Err(e)) => Err(KbError::Database(e)),
581 None => Ok(None),
582 }
583}
584
585fn decode_blob(node_id: &str, blob: &str) -> Result<Document, KbError> {
587 sexp::decode_document(blob).map_err(|e| KbError::CorruptAstBlob {
588 node_id: node_id.to_string(),
589 reason: e.to_string(),
590 })
591}
592
593pub fn get_node_row(conn: &Connection, id: &str) -> Result<Option<NodeRow>, KbError> {
599 let mut stmt = conn
600 .prepare("SELECT id, title, ast_blob, created_at, updated_at FROM nodes WHERE id = ?1")?;
601 let mut rows = stmt.query_map(params![id], |row| {
602 Ok(NodeRow {
603 id: row.get(0)?,
604 title: row.get(1)?,
605 ast_blob: row.get(2)?,
606 created_at: row.get(3)?,
607 updated_at: row.get(4)?,
608 })
609 })?;
610 match rows.next() {
611 Some(Ok(row)) => Ok(Some(row)),
612 Some(Err(e)) => Err(KbError::Database(e)),
613 None => Ok(None),
614 }
615}
616
617pub fn update_node(
625 conn: &Connection,
626 id: &str,
627 document: &Document,
628) -> Result<bool, rusqlite::Error> {
629 update_node_with(conn, id, document, None, None)
630}
631
632pub fn update_node_with(
649 conn: &Connection,
650 id: &str,
651 document: &Document,
652 embedding: Option<Vec<f32>>,
653 model: Option<&str>,
654) -> Result<bool, rusqlite::Error> {
655 let tx = conn.unchecked_transaction()?;
656
657 let prior_blob: Option<String> = tx
658 .query_row(
659 "SELECT ast_blob FROM nodes WHERE id = ?1",
660 params![id],
661 |row| row.get(0),
662 )
663 .optional()?;
664 let Some(prior_blob) = prior_blob else {
665 return Ok(false);
666 };
667
668 let blob = sexp::encode_document(document);
669 let title = extract_title(document);
670 let tags = extract_tags(document);
671 let body_text = extract_body_text(document);
672 let now = now_iso8601();
673
674 tx.execute(
675 "UPDATE nodes SET title = ?1, ast_blob = ?2, updated_at = ?3 WHERE id = ?4",
676 params![title, blob, now, id],
677 )?;
678 tx.execute("DELETE FROM node_tags WHERE node_id = ?1", params![id])?;
679 for tag in &tags {
680 tx.execute(
681 "INSERT INTO node_tags (node_id, tag) VALUES (?1, ?2)",
682 params![id, tag.0.as_str()],
683 )?;
684 }
685 tx.execute("DELETE FROM nodes_fts WHERE node_id = ?1", params![id])?;
686 tx.execute(
687 "INSERT INTO nodes_fts (node_id, title, body) VALUES (?1, ?2, ?3)",
688 params![id, title, body_text],
689 )?;
690 tx.execute(
691 "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'update', ?2, ?3, ?4)",
692 params![id, prior_blob, blob, now],
693 )?;
694
695 let prior_slug = sexp::decode_document(&prior_blob)
700 .ok()
701 .as_ref()
702 .and_then(extract_name_slug);
703 let new_slug = extract_name_slug(document);
704 if prior_slug.as_deref() != new_slug.as_deref() {
705 invalidate_resolved_pointing_at(&tx, id)?;
706 }
707 relink_one_inner(&tx, id, document)?;
708 if let Some(slug) = new_slug {
711 tx.execute(
712 "UPDATE links SET target_id = ?1 \
713 WHERE link_type = 'name' AND target_slug = ?2 AND target_id IS NULL",
714 params![id, slug],
715 )?;
716 }
717
718 tx.commit()?;
719
720 write_embedding_row(conn, id, embedding, model);
721
722 Ok(true)
723}
724
725pub fn delete_node(conn: &Connection, id: &str) -> Result<bool, rusqlite::Error> {
733 let tx = conn.unchecked_transaction()?;
734
735 let prior_blob: Option<String> = tx
736 .query_row(
737 "SELECT ast_blob FROM nodes WHERE id = ?1",
738 params![id],
739 |row| row.get(0),
740 )
741 .optional()?;
742 let Some(prior_blob) = prior_blob else {
743 return Ok(false);
744 };
745
746 tx.execute(
747 "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'delete', ?2, NULL, ?3)",
748 params![id, prior_blob, now_iso8601()],
749 )?;
750
751 tx.execute("DELETE FROM nodes_fts WHERE node_id = ?1", params![id])?;
752 tx.execute(
758 "DELETE FROM links WHERE link_type = 'id' AND target_id = ?1",
759 params![id],
760 )?;
761 tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
762
763 tx.commit()?;
764 Ok(true)
765}
766
767pub fn list_by_tag(conn: &Connection, tag: &str) -> Result<Vec<NodeRow>, rusqlite::Error> {
776 let tag = normalize_tag(tag);
777 let mut stmt = conn.prepare(
778 "SELECT n.id, n.title, n.ast_blob, n.created_at, n.updated_at \
779 FROM nodes n JOIN node_tags nt ON n.id = nt.node_id \
780 WHERE nt.tag = ?1 ORDER BY n.updated_at DESC",
781 )?;
782 let rows = stmt.query_map(params![tag], |row| {
783 Ok(NodeRow {
784 id: row.get(0)?,
785 title: row.get(1)?,
786 ast_blob: row.get(2)?,
787 created_at: row.get(3)?,
788 updated_at: row.get(4)?,
789 })
790 })?;
791 rows.collect()
792}
793
794pub fn list_recent(conn: &Connection, limit: usize) -> Result<Vec<NodeRow>, rusqlite::Error> {
800 let mut stmt = conn.prepare(
801 "SELECT id, title, ast_blob, created_at, updated_at FROM nodes ORDER BY updated_at DESC LIMIT ?1",
802 )?;
803 let rows = stmt.query_map(params![limit as i64], |row| {
804 Ok(NodeRow {
805 id: row.get(0)?,
806 title: row.get(1)?,
807 ast_blob: row.get(2)?,
808 created_at: row.get(3)?,
809 updated_at: row.get(4)?,
810 })
811 })?;
812 rows.collect()
813}
814
815pub fn list_all_nodes(
822 conn: &Connection,
823 limit: usize,
824 offset: usize,
825) -> Result<Vec<(NodeId, Title)>, rusqlite::Error> {
826 if limit == 0 {
827 return Ok(Vec::new());
828 }
829 let mut stmt =
830 conn.prepare("SELECT id, title FROM nodes ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2")?;
831 let rows = stmt.query_map(params![limit as i64, offset as i64], |row| {
832 Ok((NodeId(row.get(0)?), Title(row.get(1)?)))
833 })?;
834 rows.collect()
835}
836
837#[derive(Debug, Clone)]
839pub struct NodeFullData {
840 pub title: String,
841 pub tags: Vec<Tag>,
842 pub document: Document,
843 pub created_at: String,
844 pub updated_at: String,
845}
846
847pub fn get_node_full(conn: &Connection, id: &str) -> Result<Option<NodeFullData>, KbError> {
854 let mut stmt =
855 conn.prepare("SELECT title, ast_blob, created_at, updated_at FROM nodes WHERE id = ?1")?;
856 let mut rows = stmt.query_map(params![id], |row| {
857 Ok((
858 row.get::<_, String>(0)?,
859 row.get::<_, String>(1)?,
860 row.get::<_, String>(2)?,
861 row.get::<_, String>(3)?,
862 ))
863 })?;
864 match rows.next() {
865 Some(Ok((title, blob, created_at, updated_at))) => {
866 let doc = decode_blob(id, &blob)?;
867 let tags = get_node_tags(conn, id)?;
868 Ok(Some(NodeFullData {
869 title,
870 tags,
871 document: doc,
872 created_at,
873 updated_at,
874 }))
875 }
876 Some(Err(e)) => Err(KbError::Database(e)),
877 None => Ok(None),
878 }
879}
880
881fn get_node_tags(conn: &Connection, node_id: &str) -> Result<Vec<Tag>, rusqlite::Error> {
883 let mut stmt = conn.prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
884 let rows = stmt.query_map(params![node_id], |row| row.get::<_, String>(0))?;
885 rows.map(|r| r.map(Tag)).collect()
886}
887
888pub fn fetch_titles(
895 conn: &Connection,
896 ids: &[String],
897) -> Result<Vec<(String, String)>, rusqlite::Error> {
898 if ids.is_empty() {
899 return Ok(Vec::new());
900 }
901 let placeholders: Vec<String> = ids
902 .iter()
903 .enumerate()
904 .map(|(i, _)| format!("?{}", i + 1))
905 .collect();
906 let sql = format!(
907 "SELECT id, title FROM nodes WHERE id IN ({})",
908 placeholders.join(",")
909 );
910 let mut stmt = conn.prepare(&sql)?;
911 let bound: Vec<&dyn rusqlite::types::ToSql> = ids
912 .iter()
913 .map(|id| id as &dyn rusqlite::types::ToSql)
914 .collect();
915 let rows = stmt.query_map(bound.as_slice(), |row| {
916 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
917 })?;
918 let mut map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
919 for row in rows {
920 let (id, title) = row?;
921 map.insert(id, title);
922 }
923 Ok(ids
924 .iter()
925 .map(|id| (id.clone(), map.get(id).cloned().unwrap_or_default()))
926 .collect())
927}
928
929pub fn search_fts(conn: &Connection, query: &str) -> Result<Vec<String>, rusqlite::Error> {
935 let q = query.trim();
936 if q.is_empty() {
937 return Ok(Vec::new());
938 }
939 let terms: Vec<String> = q.split_whitespace().map(|t| format!("\"{t}\"*")).collect();
940 let fts_query = terms.join(" AND ");
941
942 let mut stmt =
943 conn.prepare("SELECT node_id FROM nodes_fts WHERE nodes_fts MATCH ?1 ORDER BY rank")?;
944 let rows = stmt.query_map(params![fts_query], |row| row.get::<_, String>(0))?;
945 let mut results = Vec::new();
946 for row in rows {
947 let id = row?;
948 if !results.contains(&id) {
949 results.push(id);
950 }
951 }
952 Ok(results)
953}
954
955#[must_use]
961pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
962 if a.len() != b.len() {
963 return 0.0;
964 }
965 let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
966 let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
967 if na == 0.0 || nb == 0.0 {
968 return 0.0;
969 }
970 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
971 dot / (na * nb)
972}
973
974#[must_use]
981pub fn reciprocal_rank_fusion(k: usize, lists: &[Vec<NodeId>]) -> Vec<NodeId> {
982 let mut scores: BTreeMap<String, f64> = BTreeMap::new();
983 for list in lists {
984 for (rank0, NodeId(id)) in list.iter().enumerate() {
985 let rank = rank0 + 1;
986 #[allow(
987 clippy::cast_precision_loss,
988 reason = "(k + rank) is a small positive rank index; the f64 cast for RRF scoring loses no significant precision"
989 )]
990 let contribution = 1.0_f64 / (k + rank) as f64;
991 *scores.entry(id.clone()).or_insert(0.0) += contribution;
992 }
993 }
994 let mut entries: Vec<(String, f64)> = scores.into_iter().collect();
995 entries.sort_by(|a, b| {
998 b.1.partial_cmp(&a.1)
999 .unwrap_or(Ordering::Equal)
1000 .then_with(|| a.0.cmp(&b.0))
1001 });
1002 entries.into_iter().map(|(s, _)| NodeId(s)).collect()
1003}
1004
1005pub fn rank_by_embedding(
1013 conn: &Connection,
1014 query_vec: &[f32],
1015 model: &str,
1016) -> Result<Vec<NodeId>, rusqlite::Error> {
1017 let mut stmt = conn.prepare("SELECT node_id, embedding FROM embeddings WHERE model = ?1")?;
1018 let rows = stmt.query_map(params![model], |row| {
1019 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1020 })?;
1021 let mut scored: Vec<(String, f32)> = Vec::new();
1022 for row in rows {
1023 let (nid, blob) = row?;
1024 if let Ok(ev) = embedding::decode_embedding(&blob) {
1025 let s = cosine_similarity(query_vec, &ev);
1026 scored.push((nid, s));
1027 }
1028 }
1029 scored.sort_by(|a, b| {
1030 b.1.partial_cmp(&a.1)
1031 .unwrap_or(Ordering::Equal)
1032 .then_with(|| a.0.cmp(&b.0))
1033 });
1034 Ok(scored.into_iter().map(|(id, _)| NodeId(id)).collect())
1035}
1036
1037pub fn search_hybrid(
1060 conn: &Connection,
1061 query: &str,
1062 query_embedding: Option<(Vec<f32>, &str)>,
1063) -> Result<Vec<NodeId>, rusqlite::Error> {
1064 let keyword_ranked: Vec<NodeId> = search_fts(conn, query)?.into_iter().map(NodeId).collect();
1065 let vector_ranked: Vec<NodeId> = match query_embedding {
1066 None => Vec::new(),
1067 Some((qv, model)) => {
1068 if query.trim().is_empty() && keyword_ranked.is_empty() {
1069 Vec::new()
1070 } else {
1071 rank_by_embedding(conn, &qv, model)?
1072 }
1073 }
1074 };
1075 Ok(reciprocal_rank_fusion(60, &[keyword_ranked, vector_ranked]))
1076}
1077
1078#[derive(Debug, thiserror::Error)]
1086pub enum VecExtensionError {
1087 #[error("enable_load_extension failed: {0}")]
1089 EnableLoad(rusqlite::Error),
1090
1091 #[error("load_extension failed: {0}")]
1093 LoadExtension(rusqlite::Error),
1094}
1095
1096pub fn try_load_vec_extension(conn: &Connection, path: &str) -> Result<(), VecExtensionError> {
1108 #[allow(
1114 unsafe_code,
1115 reason = "FFI: loads the sqlite-vec dynamic extension; soundness argued in the SAFETY comment above"
1116 )]
1117 let result: Result<(), VecExtensionError> = unsafe {
1118 let _guard =
1119 rusqlite::LoadExtensionGuard::new(conn).map_err(VecExtensionError::EnableLoad)?;
1120 conn.load_extension(path, None::<&str>)
1121 .map_err(VecExtensionError::LoadExtension)?;
1122 Ok(())
1123 };
1124 result
1125}
1126
1127#[derive(Debug, Clone, PartialEq, Eq)]
1136pub enum LinkRef {
1137 Id(String),
1139 Name(String),
1141}
1142
1143#[must_use]
1158pub fn extract_links(doc: &Document) -> Vec<LinkRef> {
1159 let mut seen_id = HashSet::<String>::new();
1160 let mut seen_name = HashSet::<String>::new();
1161 let mut out = Vec::new();
1162 collect_links_blocks(&doc.blocks, &mut seen_id, &mut seen_name, &mut out);
1163 out
1164}
1165
1166fn is_bracket_name_ref(target: &str) -> bool {
1169 !target.is_empty() && !target.contains(':') && !target.contains('/')
1170}
1171
1172fn collect_links_blocks(
1173 blocks: &[Block],
1174 seen_id: &mut HashSet<String>,
1175 seen_name: &mut HashSet<String>,
1176 out: &mut Vec<LinkRef>,
1177) {
1178 for block in blocks {
1179 match block {
1180 Block::Heading { children, .. } => {
1181 collect_links_blocks(children, seen_id, seen_name, out);
1182 }
1183 Block::Paragraph { inlines } => {
1184 for inline in inlines {
1185 collect_links_inline(inline, seen_id, seen_name, out);
1186 }
1187 }
1188 Block::QuoteBlock { children } => {
1189 collect_links_blocks(children, seen_id, seen_name, out);
1190 }
1191 Block::List { items, .. } => {
1192 for item in items {
1193 collect_links_blocks(&item.content, seen_id, seen_name, out);
1194 }
1195 }
1196 Block::Table { rows } => {
1197 for row in rows {
1198 for cell in row {
1199 for inline in &cell.inlines {
1200 collect_links_inline(inline, seen_id, seen_name, out);
1201 }
1202 }
1203 }
1204 }
1205 Block::SrcBlock { .. } | Block::ExampleBlock { .. } => {}
1207 _ => {}
1208 }
1209 }
1210}
1211
1212fn collect_links_inline(
1213 inline: &Inline,
1214 seen_id: &mut HashSet<String>,
1215 seen_name: &mut HashSet<String>,
1216 out: &mut Vec<LinkRef>,
1217) {
1218 match inline {
1219 Inline::Link {
1220 target,
1221 description,
1222 } => {
1223 if let Some(rest) = target.strip_prefix("id:") {
1224 let id = rest.to_string();
1226 if !id.is_empty() && seen_id.insert(id.clone()) {
1227 out.push(LinkRef::Id(id));
1228 }
1229 } else if description.is_none() && is_bracket_name_ref(target) {
1230 let slug = target.clone();
1231 if seen_name.insert(slug.clone()) {
1232 out.push(LinkRef::Name(slug));
1233 }
1234 }
1235 }
1236 Inline::Bold(is) | Inline::Italic(is) | Inline::Strikethrough(is) => {
1237 for i in is {
1238 collect_links_inline(i, seen_id, seen_name, out);
1239 }
1240 }
1241 Inline::InlineCode(_) | Inline::Verbatim(_) => {}
1243 _ => {}
1244 }
1245}
1246
1247#[must_use]
1250pub fn extract_name_slug(doc: &Document) -> Option<String> {
1251 for block in &doc.blocks {
1252 if let Block::Keyword { name, value } = block {
1253 if name.eq_ignore_ascii_case("name") {
1254 let trimmed = value.trim();
1255 if !trimmed.is_empty() {
1256 return Some(trimmed.to_string());
1257 }
1258 }
1259 }
1260 }
1261 None
1262}
1263
1264pub fn relink_one(
1277 conn: &Connection,
1278 source_id: &str,
1279 document: &Document,
1280) -> Result<(), rusqlite::Error> {
1281 let tx = conn.unchecked_transaction()?;
1282 relink_one_inner(&tx, source_id, document)?;
1283 tx.commit()?;
1284 Ok(())
1285}
1286
1287fn relink_one_inner(
1294 conn: &Connection,
1295 source_id: &str,
1296 document: &Document,
1297) -> Result<(), rusqlite::Error> {
1298 let refs = extract_links(document);
1299 conn.execute("DELETE FROM links WHERE source_id = ?1", params![source_id])?;
1300 if refs.is_empty() {
1301 return Ok(());
1302 }
1303 let mut ins_id = conn.prepare(
1304 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1305 VALUES (?1, 'id', ?2, NULL)",
1306 )?;
1307 let mut ins_name = conn.prepare(
1308 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1309 VALUES (?1, 'name', ?2, ?3)",
1310 )?;
1311 for r in &refs {
1312 match r {
1313 LinkRef::Id(tgt) => {
1314 if node_exists(conn, tgt)? {
1315 ins_id.execute(params![source_id, tgt])?;
1316 }
1317 }
1318 LinkRef::Name(slug) => {
1319 let dst_id = resolve_slug_to_id(conn, slug)?;
1320 ins_name.execute(params![source_id, dst_id, slug])?;
1321 }
1322 }
1323 }
1324 Ok(())
1325}
1326
1327fn node_exists(conn: &Connection, id: &str) -> Result<bool, rusqlite::Error> {
1328 let n: Option<i64> = conn
1329 .query_row(
1330 "SELECT 1 FROM nodes WHERE id = ?1 LIMIT 1",
1331 params![id],
1332 |r| r.get(0),
1333 )
1334 .optional()?;
1335 Ok(n.is_some())
1336}
1337
1338pub fn relink_all(conn: &Connection) -> Result<(usize, usize), rusqlite::Error> {
1351 let rows: Vec<(String, String)> = {
1352 let mut stmt = conn.prepare("SELECT id, ast_blob FROM nodes")?;
1353 let mapped = stmt.query_map([], |row| {
1354 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1355 })?;
1356 mapped.collect::<Result<_, _>>()?
1357 };
1358
1359 let tx = conn.unchecked_transaction()?;
1360 tx.execute("DELETE FROM links", [])?;
1361
1362 let mut nodes_processed: usize = 0;
1363 let mut links_written: usize = 0;
1364 {
1365 let mut ins_id = tx.prepare(
1366 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1367 VALUES (?1, 'id', ?2, NULL)",
1368 )?;
1369 let mut ins_name = tx.prepare(
1370 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1371 VALUES (?1, 'name', ?2, ?3)",
1372 )?;
1373 for (nid, blob) in &rows {
1374 nodes_processed += 1;
1375 let Ok(doc) = sexp::decode_document(blob) else {
1376 continue;
1377 };
1378 for r in extract_links(&doc) {
1379 match r {
1380 LinkRef::Id(tgt) => {
1381 if node_exists(&tx, &tgt)? {
1382 ins_id.execute(params![nid, &tgt])?;
1383 links_written += 1;
1384 }
1385 }
1386 LinkRef::Name(slug) => {
1387 let dst_id = resolve_slug_to_id(&tx, &slug)?;
1388 ins_name.execute(params![nid, dst_id, &slug])?;
1389 links_written += 1;
1390 }
1391 }
1392 }
1393 }
1394 }
1395
1396 tx.commit()?;
1397 Ok((nodes_processed, links_written))
1398}
1399
1400fn resolve_slug_to_id(conn: &Connection, slug: &str) -> Result<Option<String>, rusqlite::Error> {
1404 let mut stmt = conn.prepare("SELECT id, ast_blob FROM nodes ORDER BY id")?;
1405 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
1406 for row in rows {
1407 let (id, blob) = row?;
1408 let Ok(doc) = sexp::decode_document(&blob) else {
1409 continue;
1410 };
1411 if let Some(node_slug) = extract_name_slug(&doc) {
1412 if node_slug == slug {
1413 return Ok(Some(id));
1414 }
1415 }
1416 }
1417 Ok(None)
1418}
1419
1420fn invalidate_resolved_pointing_at(
1425 conn: &Connection,
1426 node_id: &str,
1427) -> Result<(), rusqlite::Error> {
1428 conn.execute(
1429 "UPDATE links SET target_id = NULL \
1430 WHERE link_type = 'name' AND target_id = ?1",
1431 params![node_id],
1432 )?;
1433 Ok(())
1434}
1435
1436pub fn get_neighborhood(conn: &Connection, node_id: &str) -> Result<Neighborhood, rusqlite::Error> {
1448 let outgoing: Vec<(NodeId, LinkType)> = {
1449 let mut stmt = conn.prepare(
1450 "SELECT target_id, link_type FROM links \
1451 WHERE source_id = ?1 AND target_id IS NOT NULL",
1452 )?;
1453 let rows = stmt.query_map(params![node_id], |row| {
1454 Ok((NodeId(row.get(0)?), row.get::<_, LinkType>(1)?))
1455 })?;
1456 rows.collect::<Result<_, _>>()?
1457 };
1458 let incoming: Vec<(NodeId, LinkType)> = {
1459 let mut stmt =
1460 conn.prepare("SELECT source_id, link_type FROM links WHERE target_id = ?1")?;
1461 let rows = stmt.query_map(params![node_id], |row| {
1462 Ok((NodeId(row.get(0)?), row.get::<_, LinkType>(1)?))
1463 })?;
1464 rows.collect::<Result<_, _>>()?
1465 };
1466 Ok(Neighborhood { outgoing, incoming })
1467}
1468
1469#[derive(Debug, Clone, Default, PartialEq, Eq)]
1475pub struct LinkNeighborhood {
1476 pub outgoing: Vec<LinkRow>,
1479 pub incoming: Vec<LinkRow>,
1482}
1483
1484pub fn get_links(conn: &Connection, node_id: &str) -> Result<LinkNeighborhood, rusqlite::Error> {
1492 let outgoing: Vec<LinkRow> = {
1493 let mut stmt = conn.prepare(
1494 "SELECT source_id, link_type, target_id, target_slug FROM links \
1495 WHERE source_id = ?1 \
1496 ORDER BY link_type, target_slug, target_id",
1497 )?;
1498 let rows = stmt.query_map(params![node_id], |r| {
1499 Ok(LinkRow {
1500 source_id: r.get(0)?,
1501 link_type: r.get(1)?,
1502 target_id: r.get(2)?,
1503 target_slug: r.get(3)?,
1504 })
1505 })?;
1506 rows.collect::<Result<_, _>>()?
1507 };
1508 let incoming: Vec<LinkRow> = {
1509 let mut stmt = conn.prepare(
1510 "SELECT source_id, link_type, target_id, target_slug FROM links \
1511 WHERE target_id = ?1 \
1512 ORDER BY link_type, source_id",
1513 )?;
1514 let rows = stmt.query_map(params![node_id], |r| {
1515 Ok(LinkRow {
1516 source_id: r.get(0)?,
1517 link_type: r.get(1)?,
1518 target_id: r.get(2)?,
1519 target_slug: r.get(3)?,
1520 })
1521 })?;
1522 rows.collect::<Result<_, _>>()?
1523 };
1524 Ok(LinkNeighborhood { outgoing, incoming })
1525}
1526
1527pub fn list_orphans(conn: &Connection) -> Result<Vec<NodeRow>, rusqlite::Error> {
1537 let mut stmt = conn.prepare(
1538 "SELECT n.id, n.title, n.ast_blob, n.created_at, n.updated_at \
1539 FROM nodes n \
1540 WHERE NOT EXISTS ( \
1541 SELECT 1 FROM links l \
1542 WHERE l.target_id = n.id \
1543 ) \
1544 ORDER BY n.updated_at DESC",
1545 )?;
1546 let rows = stmt.query_map([], |row| {
1547 Ok(NodeRow {
1548 id: row.get(0)?,
1549 title: row.get(1)?,
1550 ast_blob: row.get(2)?,
1551 created_at: row.get(3)?,
1552 updated_at: row.get(4)?,
1553 })
1554 })?;
1555 rows.collect()
1556}
1557
1558#[derive(Debug, Clone, PartialEq, Eq)]
1561pub struct HubEntry {
1562 pub id: String,
1563 pub title: String,
1564 pub in_degree: i64,
1565}
1566
1567pub fn list_hubs(conn: &Connection, limit: usize) -> Result<Vec<HubEntry>, rusqlite::Error> {
1575 if limit == 0 {
1576 return Ok(Vec::new());
1577 }
1578 let mut stmt = conn.prepare(
1579 "SELECT n.id, n.title, COUNT(l.source_id) AS in_degree \
1580 FROM nodes n \
1581 JOIN links l ON l.target_id = n.id \
1582 GROUP BY n.id, n.title \
1583 ORDER BY in_degree DESC, n.id ASC \
1584 LIMIT ?1",
1585 )?;
1586 let rows = stmt.query_map(params![limit as i64], |r| {
1587 Ok(HubEntry {
1588 id: r.get(0)?,
1589 title: r.get(1)?,
1590 in_degree: r.get(2)?,
1591 })
1592 })?;
1593 rows.collect()
1594}
1595
1596pub fn list_broken_links(conn: &Connection) -> Result<Vec<LinkRow>, rusqlite::Error> {
1608 let mut stmt = conn.prepare(
1609 "SELECT source_id, link_type, target_id, target_slug FROM links \
1610 WHERE link_type = 'name' AND target_id IS NULL \
1611 ORDER BY target_slug ASC, source_id ASC",
1612 )?;
1613 let rows = stmt.query_map([], |r| {
1614 Ok(LinkRow {
1615 source_id: r.get(0)?,
1616 link_type: r.get(1)?,
1617 target_id: r.get(2)?,
1618 target_slug: r.get(3)?,
1619 })
1620 })?;
1621 rows.collect()
1622}
1623
1624#[must_use]
1630pub fn extract_title(doc: &Document) -> String {
1631 title_keyword(&doc.blocks)
1632 .or_else(|| find_first_title(&doc.blocks))
1633 .map(|t| truncate80(t.trim()))
1634 .unwrap_or_else(|| "(untitled)".into())
1635}
1636
1637fn title_keyword(blocks: &[Block]) -> Option<String> {
1639 blocks.iter().find_map(|block| match block {
1640 Block::Keyword { name, value } if name.eq_ignore_ascii_case("title") => {
1641 let trimmed = value.trim();
1642 (!trimmed.is_empty()).then(|| trimmed.to_string())
1643 }
1644 _ => None,
1645 })
1646}
1647
1648fn find_first_title(blocks: &[Block]) -> Option<String> {
1649 for block in blocks {
1650 match block {
1651 Block::Heading {
1652 title, children, ..
1653 } => {
1654 if !title.0.trim().is_empty() {
1655 return Some(title.0.clone());
1656 }
1657 if let Some(t) = find_first_title(children) {
1658 return Some(t);
1659 }
1660 }
1661 Block::Paragraph { inlines } => {
1662 let text = inline_text(inlines);
1663 if !text.trim().is_empty() {
1664 return Some(text);
1665 }
1666 }
1667 Block::QuoteBlock { children } => {
1668 if let Some(t) = find_first_title(children) {
1669 return Some(t);
1670 }
1671 }
1672 _ => {}
1673 }
1674 }
1675 None
1676}
1677
1678fn truncate80(s: &str) -> String {
1679 if s.chars().count() <= 80 {
1680 s.to_string()
1681 } else {
1682 s.chars().take(80).collect()
1683 }
1684}
1685
1686#[must_use]
1690pub fn extract_tags(doc: &Document) -> Vec<Tag> {
1691 let mut seen = HashSet::new();
1692 let mut result = Vec::new();
1693 collect_tags(&doc.blocks, &mut seen, &mut result);
1694 result
1695}
1696
1697#[must_use]
1706pub fn normalize_tag(raw: &str) -> String {
1707 let mut out = String::with_capacity(raw.len());
1708 let mut pending_sep = false;
1709 for ch in raw.chars() {
1710 if ch.is_alphanumeric() {
1711 if pending_sep && !out.is_empty() {
1712 out.push('-');
1713 }
1714 pending_sep = false;
1715 out.extend(ch.to_lowercase());
1716 } else {
1717 pending_sep = true;
1718 }
1719 }
1720 out
1721}
1722
1723fn parse_filetags(value: &str) -> impl Iterator<Item = String> + '_ {
1729 value
1730 .split(':')
1731 .map(str::trim)
1732 .filter(|s| !s.is_empty())
1733 .map(str::to_string)
1734}
1735
1736fn push_tag(raw: &str, seen: &mut HashSet<String>, out: &mut Vec<Tag>) {
1738 let tag = normalize_tag(raw);
1739 if !tag.is_empty() && seen.insert(tag.clone()) {
1740 out.push(Tag(tag));
1741 }
1742}
1743
1744fn collect_tags(blocks: &[Block], seen: &mut HashSet<String>, out: &mut Vec<Tag>) {
1745 for block in blocks {
1746 match block {
1747 Block::Heading { tags, children, .. } => {
1748 for tag in tags {
1749 push_tag(&tag.0, seen, out);
1750 }
1751 collect_tags(children, seen, out);
1752 }
1753 Block::QuoteBlock { children } => {
1754 collect_tags(children, seen, out);
1755 }
1756 Block::Keyword { name, value } if name.eq_ignore_ascii_case("filetags") => {
1757 for tag in parse_filetags(value) {
1758 push_tag(&tag, seen, out);
1759 }
1760 }
1761 _ => {}
1762 }
1763 }
1764}
1765
1766pub fn extract_body_text(doc: &Document) -> String {
1768 let mut texts = Vec::new();
1769 collect_texts(&doc.blocks, &mut texts);
1770 texts
1771 .into_iter()
1772 .filter(|s| !s.is_empty())
1773 .collect::<Vec<_>>()
1774 .join(" ")
1775}
1776
1777fn collect_texts(blocks: &[Block], out: &mut Vec<String>) {
1778 for block in blocks {
1779 match block {
1780 Block::Heading { children, .. } => collect_texts(children, out),
1781 Block::Paragraph { inlines } => {
1782 out.push(inline_text(inlines));
1783 }
1784 Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => {
1785 out.push(content.clone());
1786 }
1787 Block::QuoteBlock { children } => collect_texts(children, out),
1788 Block::List { items, .. } => {
1789 for item in items {
1790 collect_texts(&item.content, out);
1791 }
1792 }
1793 Block::Table { rows } => {
1794 for row in rows {
1795 for cell in row {
1796 out.push(inline_text(&cell.inlines));
1797 }
1798 }
1799 }
1800 Block::PropertyDrawer { entries } => {
1801 for (_, v) in entries {
1802 out.push(v.clone());
1803 }
1804 }
1805 Block::LogbookDrawer { entries } => {
1806 for entry in entries {
1807 out.push(entry.note.clone());
1808 }
1809 }
1810 Block::Comment { text } => {
1811 out.push(text.clone());
1812 }
1813 Block::Keyword { value, .. } => {
1814 out.push(value.clone());
1815 }
1816 Block::Planning { .. } | Block::BlankLine | Block::HorizontalRule => {}
1817 }
1818 }
1819}
1820
1821fn inline_text(inlines: &[Inline]) -> String {
1822 let mut parts = Vec::new();
1823 for inline in inlines {
1824 match inline {
1825 Inline::Plain(t) => parts.push(t.clone()),
1826 Inline::Bold(is) | Inline::Italic(is) | Inline::Strikethrough(is) => {
1827 parts.push(inline_text(is));
1828 }
1829 Inline::InlineCode(t) | Inline::Verbatim(t) => parts.push(t.clone()),
1830 Inline::Link {
1831 description: Some(d),
1832 ..
1833 } => parts.push(d.clone()),
1834 Inline::Link { target, .. } => parts.push(target.clone()),
1835 Inline::LineBreak => parts.push(" ".to_string()),
1836 }
1837 }
1838 parts.join("")
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843 use super::*;
1844
1845 fn setup() -> Connection {
1846 let conn = Connection::open_in_memory().unwrap();
1847 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
1848 init_db(&conn).unwrap();
1849 conn
1850 }
1851
1852 fn sample_doc() -> Document {
1853 Document {
1854 blocks: vec![Block::Heading {
1855 level: 1,
1856 title: Title("Test".into()),
1857 tags: vec![Tag("rust".into())],
1858 children: vec![Block::Paragraph {
1859 inlines: vec![Inline::Plain("hello world".into())],
1860 }],
1861 }],
1862 }
1863 }
1864
1865 fn link_doc(targets: &[&str]) -> Document {
1866 let inlines: Vec<Inline> = targets
1867 .iter()
1868 .map(|t| Inline::Link {
1869 target: format!("id:{t}"),
1870 description: None,
1871 })
1872 .collect();
1873 Document {
1874 blocks: vec![Block::Paragraph { inlines }],
1875 }
1876 }
1877
1878 #[test]
1881 fn insert_and_get_node() {
1882 let conn = setup();
1883 let id = "test-1";
1884 insert_node(&conn, id, &sample_doc()).unwrap();
1885 let doc = get_node(&conn, id).unwrap().unwrap();
1886 assert_eq!(doc, sample_doc());
1887 }
1888
1889 #[test]
1890 fn get_nonexistent_node() {
1891 let conn = setup();
1892 let doc = get_node(&conn, "no-such-id").unwrap();
1893 assert!(doc.is_none());
1894 }
1895
1896 #[test]
1897 fn update_node_works() {
1898 let conn = setup();
1899 let id = "test-2";
1900 insert_node(&conn, id, &sample_doc()).unwrap();
1901 let mut updated_doc = sample_doc();
1902 updated_doc.blocks.push(Block::Paragraph {
1903 inlines: vec![Inline::Plain("extra".into())],
1904 });
1905 let ok = update_node(&conn, id, &updated_doc).unwrap();
1906 assert!(ok);
1907 let doc = get_node(&conn, id).unwrap().unwrap();
1908 assert_eq!(doc, updated_doc);
1909 }
1910
1911 #[test]
1912 fn update_nonexistent_returns_false() {
1913 let conn = setup();
1914 let ok = update_node(&conn, "no-such", &sample_doc()).unwrap();
1915 assert!(!ok);
1916 }
1917
1918 #[test]
1919 fn delete_node_works() {
1920 let conn = setup();
1921 let id = "test-3";
1922 insert_node(&conn, id, &sample_doc()).unwrap();
1923 let ok = delete_node(&conn, id).unwrap();
1924 assert!(ok);
1925 assert!(get_node(&conn, id).unwrap().is_none());
1926 }
1927
1928 #[test]
1929 fn delete_nonexistent_returns_false() {
1930 let conn = setup();
1931 let ok = delete_node(&conn, "no-such").unwrap();
1932 assert!(!ok);
1933 }
1934
1935 #[test]
1936 fn list_by_tag_finds_match() {
1937 let conn = setup();
1938 insert_node(&conn, "a", &sample_doc()).unwrap();
1939
1940 let doc_b = Document {
1941 blocks: vec![Block::Heading {
1942 level: 1,
1943 title: Title("Other".into()),
1944 tags: vec![Tag("python".into())],
1945 children: vec![],
1946 }],
1947 };
1948 insert_node(&conn, "b", &doc_b).unwrap();
1949
1950 let results = list_by_tag(&conn, "rust").unwrap();
1951 assert_eq!(results.len(), 1);
1952 assert_eq!(results[0].id.as_str(), "a");
1953 }
1954
1955 #[test]
1956 fn list_recent_newest_first() {
1957 let conn = setup();
1958 insert_node(&conn, "first", &sample_doc()).unwrap();
1959 std::thread::sleep(std::time::Duration::from_millis(10));
1960 insert_node(&conn, "second", &sample_doc()).unwrap();
1961 let recent = list_recent(&conn, 10).unwrap();
1962 assert_eq!(recent.first().unwrap().id.as_str(), "second");
1963 }
1964
1965 #[test]
1966 fn title_from_first_heading() {
1967 let doc = Document {
1968 blocks: vec![Block::Heading {
1969 level: 1,
1970 title: Title("My Note".into()),
1971 tags: vec![],
1972 children: vec![],
1973 }],
1974 };
1975 assert_eq!(extract_title(&doc), "My Note");
1976 }
1977
1978 #[test]
1979 fn title_from_paragraph_fallback() {
1980 let doc = Document {
1981 blocks: vec![Block::Paragraph {
1982 inlines: vec![Inline::Plain("First paragraph text".into())],
1983 }],
1984 };
1985 assert_eq!(extract_title(&doc), "First paragraph text");
1986 }
1987
1988 #[test]
1989 fn title_untitled_when_empty() {
1990 let doc = Document { blocks: vec![] };
1991 assert_eq!(extract_title(&doc), "(untitled)");
1992 }
1993
1994 #[test]
1995 fn title_truncated_at_80() {
1996 let long = "a".repeat(100);
1997 let doc = Document {
1998 blocks: vec![Block::Paragraph {
1999 inlines: vec![Inline::Plain(long)],
2000 }],
2001 };
2002 let title = extract_title(&doc);
2003 assert_eq!(title.chars().count(), 80);
2004 }
2005
2006 #[test]
2007 fn extract_tags_collects_all() {
2008 let doc = Document {
2009 blocks: vec![
2010 Block::Heading {
2011 level: 1,
2012 title: Title("A".into()),
2013 tags: vec![Tag("rust".into()), Tag("kb".into())],
2014 children: vec![Block::Heading {
2015 level: 2,
2016 title: Title("B".into()),
2017 tags: vec![Tag("testing".into())],
2018 children: vec![],
2019 }],
2020 },
2021 Block::Heading {
2022 level: 1,
2023 title: Title("C".into()),
2024 tags: vec![Tag("rust".into())], children: vec![],
2026 },
2027 ],
2028 };
2029 let tags = extract_tags(&doc);
2030 assert_eq!(tags.len(), 3);
2031 assert_eq!(tags[0].0, "rust");
2032 assert_eq!(tags[1].0, "kb");
2033 assert_eq!(tags[2].0, "testing");
2034 }
2035
2036 #[test]
2037 fn extract_tags_includes_filetags_keyword() {
2038 let doc = Document {
2040 blocks: vec![
2041 Block::Keyword {
2042 name: "filetags".into(),
2043 value: " :design:claude-memory:project:adr:".into(),
2044 },
2045 Block::Heading {
2046 level: 1,
2047 title: Title("Decision".into()),
2048 tags: vec![Tag("design".into())], children: vec![],
2050 },
2051 ],
2052 };
2053 let extracted = extract_tags(&doc);
2054 let tags: Vec<&str> = extracted.iter().map(|t| t.0.as_str()).collect();
2055 assert_eq!(tags, ["design", "claude-memory", "project", "adr"]);
2056 }
2057
2058 #[test]
2059 fn normalize_tag_lowercase_kebab() {
2060 assert_eq!(normalize_tag("Rust"), "rust");
2061 assert_eq!(normalize_tag("Claude Memory"), "claude-memory");
2062 assert_eq!(normalize_tag("claude_memory"), "claude-memory");
2063 assert_eq!(normalize_tag("silent-critic"), "silent-critic");
2064 assert_eq!(normalize_tag("silentCritic"), "silentcritic");
2065 assert_eq!(normalize_tag(" spaced tag "), "spaced-tag");
2066 assert_eq!(normalize_tag("+++"), "");
2067 }
2068
2069 #[test]
2070 fn extract_tags_normalizes_heading_tags() {
2071 let doc = Document {
2072 blocks: vec![Block::Heading {
2073 level: 1,
2074 title: Title("H".into()),
2075 tags: vec![Tag("Rust".into()), Tag("rust".into())],
2076 children: vec![],
2077 }],
2078 };
2079 let tags = extract_tags(&doc);
2080 assert_eq!(tags.len(), 1);
2082 assert_eq!(tags[0].0, "rust");
2083 }
2084
2085 #[test]
2086 fn extract_title_prefers_title_keyword() {
2087 let doc = Document {
2088 blocks: vec![
2089 Block::Keyword {
2090 name: "title".into(),
2091 value: " The Real Title".into(),
2092 },
2093 Block::Heading {
2094 level: 1,
2095 title: Title("First Heading".into()),
2096 tags: vec![],
2097 children: vec![],
2098 },
2099 ],
2100 };
2101 assert_eq!(extract_title(&doc), "The Real Title");
2102 }
2103
2104 #[test]
2105 fn extract_title_falls_back_to_heading_without_keyword() {
2106 let doc = Document {
2107 blocks: vec![Block::Heading {
2108 level: 1,
2109 title: Title("First Heading".into()),
2110 tags: vec![],
2111 children: vec![],
2112 }],
2113 };
2114 assert_eq!(extract_title(&doc), "First Heading");
2115 }
2116
2117 #[test]
2118 fn sexp_blob_roundtrip() {
2119 let conn = setup();
2120 let id = "sexp-test";
2121 insert_node(&conn, id, &sample_doc()).unwrap();
2122 let row = get_node_row(&conn, id).unwrap().unwrap();
2123 assert!(row.ast_blob.starts_with("(kb-doc 1"));
2124 let decoded = sexp::decode_document(&row.ast_blob).unwrap();
2125 assert_eq!(decoded, sample_doc());
2126 }
2127
2128 #[test]
2131 fn schema_version_constant_is_two() {
2132 assert_eq!(CURRENT_SCHEMA_VERSION, 2);
2133 }
2134
2135 #[test]
2136 fn migration_reencodes_legacy_ordered_list_blob() {
2137 let conn = setup();
2138 let id = "11111111-1111-1111-1111-111111111111";
2139 insert_node(&conn, id, &sample_doc()).unwrap();
2140 let legacy = "(kb-doc 1 (list ordered (item no-checkbox (paragraph (plain \"x\")))))";
2143 conn.execute(
2144 "UPDATE nodes SET ast_blob = ?1 WHERE id = ?2",
2145 params![legacy, id],
2146 )
2147 .unwrap();
2148 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2149
2150 init_db(&conn).unwrap();
2151
2152 let row = get_node_row(&conn, id).unwrap().unwrap();
2153 assert!(row.ast_blob.contains("(list (ordered 1)"));
2154 let full = get_node_full(&conn, id).unwrap().unwrap();
2155 assert_eq!(
2156 full.document.blocks,
2157 vec![Block::List {
2158 list_type: ListType::Ordered(1),
2159 items: vec![ListItem {
2160 content: vec![Block::Paragraph {
2161 inlines: vec![Inline::Plain("x".into())],
2162 }],
2163 checkbox: Checkbox::NoCheckbox,
2164 }],
2165 }]
2166 );
2167 }
2168
2169 #[test]
2170 fn migration_skipped_at_current_version() {
2171 let conn = setup();
2172 let id = "22222222-2222-2222-2222-222222222222";
2173 insert_node(&conn, id, &sample_doc()).unwrap();
2174 let legacy = "(kb-doc 1 (list ordered (item no-checkbox (paragraph (plain \"x\")))))";
2177 conn.execute(
2178 "UPDATE nodes SET ast_blob = ?1 WHERE id = ?2",
2179 params![legacy, id],
2180 )
2181 .unwrap();
2182
2183 init_db(&conn).unwrap();
2184
2185 let row = get_node_row(&conn, id).unwrap().unwrap();
2186 assert_eq!(row.ast_blob, legacy);
2187 }
2188
2189 #[test]
2190 fn get_node_reports_corrupt_blob() {
2191 let conn = setup();
2192 let id = "44444444-4444-4444-4444-444444444444";
2193 insert_node(&conn, id, &sample_doc()).unwrap();
2194 conn.execute(
2195 "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2196 params![id],
2197 )
2198 .unwrap();
2199 match get_node(&conn, id).unwrap_err() {
2200 KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2201 other => panic!("expected CorruptAstBlob, got {other}"),
2202 }
2203 }
2204
2205 #[test]
2206 fn get_node_full_reports_corrupt_blob() {
2207 let conn = setup();
2208 let id = "55555555-5555-5555-5555-555555555555";
2209 insert_node(&conn, id, &sample_doc()).unwrap();
2210 conn.execute(
2211 "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2212 params![id],
2213 )
2214 .unwrap();
2215 match get_node_full(&conn, id).unwrap_err() {
2216 KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2217 other => panic!("expected CorruptAstBlob, got {other}"),
2218 }
2219 }
2220
2221 #[test]
2222 fn migration_reports_corrupt_blob() {
2223 let conn = setup();
2224 let id = "33333333-3333-3333-3333-333333333333";
2225 insert_node(&conn, id, &sample_doc()).unwrap();
2226 conn.execute(
2227 "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2228 params![id],
2229 )
2230 .unwrap();
2231 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2232
2233 let err = init_db(&conn).unwrap_err();
2234 match err {
2235 KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2236 other => panic!("expected CorruptAstBlob, got {other}"),
2237 }
2238 }
2239
2240 #[test]
2241 fn schema_version_pragma_is_stamped() {
2242 let conn = setup();
2243 let v: i64 = conn
2244 .query_row("PRAGMA user_version", [], |r| r.get(0))
2245 .unwrap();
2246 assert_eq!(v, i64::from(CURRENT_SCHEMA_VERSION));
2247 }
2248
2249 #[test]
2250 fn schema_links_table_columns_and_pk() {
2251 let conn = setup();
2252 let cols: Vec<(String, String, i64, i64)> = conn
2253 .prepare("PRAGMA table_info(links)")
2254 .unwrap()
2255 .query_map([], |r| {
2256 Ok((
2257 r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(5)?, ))
2262 })
2263 .unwrap()
2264 .collect::<Result<_, _>>()
2265 .unwrap();
2266 assert_eq!(
2271 cols,
2272 vec![
2273 ("source_id".into(), "TEXT".into(), 1, 1),
2274 ("link_type".into(), "TEXT".into(), 1, 2),
2275 ("target_id".into(), "TEXT".into(), 0, 3),
2276 ("target_slug".into(), "TEXT".into(), 0, 4),
2277 ]
2278 );
2279 }
2280
2281 #[test]
2282 fn schema_links_table_foreign_keys() {
2283 let conn = setup();
2284 let fks: Vec<(String, String, String, String)> = conn
2285 .prepare("PRAGMA foreign_key_list(links)")
2286 .unwrap()
2287 .query_map([], |r| {
2288 Ok((
2289 r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?, r.get::<_, String>(6)?, ))
2294 })
2295 .unwrap()
2296 .collect::<Result<_, _>>()
2297 .unwrap();
2298 assert_eq!(fks.len(), 2);
2299 let mut by_from: std::collections::HashMap<String, (String, String, String)> =
2300 std::collections::HashMap::new();
2301 for (table, from, to, on_delete) in fks {
2302 by_from.insert(from, (table, to, on_delete));
2303 }
2304 let src = by_from.get("source_id").expect("source_id FK present");
2305 assert_eq!(src.0, "nodes");
2306 assert_eq!(src.1, "id");
2307 assert_eq!(src.2, "CASCADE");
2308 let tgt = by_from.get("target_id").expect("target_id FK present");
2309 assert_eq!(tgt.0, "nodes");
2310 assert_eq!(tgt.1, "id");
2311 assert_eq!(tgt.2, "SET NULL");
2315 }
2316
2317 #[test]
2318 fn links_table_check_constraint_rejects_invalid_shapes() {
2319 let conn = setup();
2320 insert_node(&conn, "src", &empty_doc()).unwrap();
2322 let e = conn.execute(
2324 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2325 VALUES ('src', 'id', NULL, NULL)",
2326 [],
2327 );
2328 assert!(e.is_err());
2329 let e = conn.execute(
2331 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2332 VALUES ('src', 'id', 'src', 'slug')",
2333 [],
2334 );
2335 assert!(e.is_err());
2336 let e = conn.execute(
2338 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2339 VALUES ('src', 'name', NULL, NULL)",
2340 [],
2341 );
2342 assert!(e.is_err());
2343 }
2344
2345 #[test]
2346 fn schema_name_links_table_does_not_exist() {
2347 let conn = setup();
2348 let n: i64 = conn
2349 .query_row(
2350 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
2351 [],
2352 |r| r.get(0),
2353 )
2354 .unwrap();
2355 assert_eq!(n, 0, "pre-consolidation name_links table must not exist");
2356 }
2357
2358 #[test]
2359 fn schema_links_target_index_present() {
2360 let conn = setup();
2361 let n: i64 = conn
2362 .query_row(
2363 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='links_target_idx' AND tbl_name='links'",
2364 [],
2365 |r| r.get(0),
2366 )
2367 .unwrap();
2368 assert_eq!(n, 1);
2369 }
2370
2371 #[test]
2372 fn schema_audit_log_columns() {
2373 let conn = setup();
2374 let cols: Vec<(String, String, i64, i64)> = conn
2375 .prepare("PRAGMA table_info(audit_log)")
2376 .unwrap()
2377 .query_map([], |r| {
2378 Ok((
2379 r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(5)?, ))
2384 })
2385 .unwrap()
2386 .collect::<Result<_, _>>()
2387 .unwrap();
2388 assert_eq!(
2389 cols,
2390 vec![
2391 ("id".into(), "INTEGER".into(), 0, 1),
2392 ("node_id".into(), "TEXT".into(), 1, 0),
2393 ("operation".into(), "TEXT".into(), 1, 0),
2394 ("old_blob".into(), "TEXT".into(), 0, 0),
2395 ("new_blob".into(), "TEXT".into(), 0, 0),
2396 ("timestamp".into(), "TEXT".into(), 1, 0),
2397 ]
2398 );
2399 }
2400
2401 #[test]
2402 fn schema_audit_log_has_no_foreign_keys() {
2403 let conn = setup();
2404 let n: i64 = conn
2405 .query_row(
2406 "SELECT COUNT(*) FROM pragma_foreign_key_list('audit_log')",
2407 [],
2408 |r| r.get(0),
2409 )
2410 .unwrap();
2411 assert_eq!(n, 0);
2412 }
2413
2414 #[test]
2415 fn schema_audit_log_autoincrement_works() {
2416 let conn = setup();
2417 conn.execute(
2418 "INSERT INTO audit_log (node_id, operation, timestamp) VALUES ('x', 'insert', 'now')",
2419 [],
2420 )
2421 .unwrap();
2422 conn.execute(
2423 "INSERT INTO audit_log (node_id, operation, timestamp) VALUES ('y', 'insert', 'now')",
2424 [],
2425 )
2426 .unwrap();
2427 let ids: Vec<i64> = conn
2428 .prepare("SELECT id FROM audit_log ORDER BY id")
2429 .unwrap()
2430 .query_map([], |r| r.get::<_, i64>(0))
2431 .unwrap()
2432 .collect::<Result<_, _>>()
2433 .unwrap();
2434 assert_eq!(ids, vec![1, 2]);
2435 let n: i64 = conn
2437 .query_row(
2438 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'",
2439 [],
2440 |r| r.get(0),
2441 )
2442 .unwrap();
2443 assert_eq!(n, 1);
2444 }
2445
2446 #[test]
2447 fn schema_audit_log_indices_present() {
2448 let conn = setup();
2449 for name in ["audit_log_node_idx", "audit_log_ts_idx"] {
2450 let n: i64 = conn
2451 .query_row(
2452 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?1 AND tbl_name='audit_log'",
2453 params![name],
2454 |r| r.get(0),
2455 )
2456 .unwrap();
2457 assert_eq!(n, 1, "missing index {name}");
2458 }
2459 }
2460
2461 fn fetch_audit_rows(conn: &Connection, node_id: &str) -> Vec<AuditRow> {
2464 conn.prepare(
2465 "SELECT id, node_id, operation, old_blob, new_blob, timestamp FROM audit_log WHERE node_id = ?1 ORDER BY id",
2466 )
2467 .unwrap()
2468 .query_map(params![node_id], |r| {
2469 Ok(AuditRow {
2470 id: r.get(0)?,
2471 node_id: r.get(1)?,
2472 operation: r.get(2)?,
2473 old_blob: r.get(3)?,
2474 new_blob: r.get(4)?,
2475 timestamp: r.get(5)?,
2476 })
2477 })
2478 .unwrap()
2479 .collect::<Result<_, _>>()
2480 .unwrap()
2481 }
2482
2483 #[test]
2484 fn audit_writers_insert_writes_one_row() {
2485 let conn = setup();
2486 insert_node(&conn, "n", &sample_doc()).unwrap();
2487 let rows = fetch_audit_rows(&conn, "n");
2488 assert_eq!(rows.len(), 1);
2489 assert_eq!(rows[0].operation, "insert");
2490 assert!(rows[0].old_blob.is_none());
2491 assert!(
2492 rows[0]
2493 .new_blob
2494 .as_deref()
2495 .unwrap()
2496 .starts_with("(kb-doc 1")
2497 );
2498 let ts = &rows[0].timestamp;
2500 assert!(
2501 ts.ends_with('Z') && ts.contains('.'),
2502 "timestamp not ISO8601-ms: {ts}"
2503 );
2504 }
2505
2506 #[test]
2507 fn audit_writers_update_records_old_and_new() {
2508 let conn = setup();
2509 insert_node(&conn, "n", &sample_doc()).unwrap();
2510 let mut next = sample_doc();
2511 next.blocks.push(Block::Paragraph {
2512 inlines: vec![Inline::Plain("more".into())],
2513 });
2514 update_node(&conn, "n", &next).unwrap();
2515 let rows = fetch_audit_rows(&conn, "n");
2516 assert_eq!(rows.len(), 2);
2517 assert_eq!(rows[1].operation, "update");
2518 let old = rows[1].old_blob.as_deref().unwrap();
2519 let new = rows[1].new_blob.as_deref().unwrap();
2520 assert_ne!(old, new);
2521 assert!(new.contains("more"));
2522 }
2523
2524 #[test]
2525 fn audit_writers_delete_records_old_only() {
2526 let conn = setup();
2527 insert_node(&conn, "n", &sample_doc()).unwrap();
2528 delete_node(&conn, "n").unwrap();
2529 let rows = fetch_audit_rows(&conn, "n");
2530 assert_eq!(rows.len(), 2);
2532 assert_eq!(rows[1].operation, "delete");
2533 assert!(rows[1].old_blob.is_some());
2534 assert!(rows[1].new_blob.is_none());
2535 }
2536
2537 #[test]
2538 fn audit_writers_timestamp_format_matches_node_created_at() {
2539 let conn = setup();
2540 insert_node(&conn, "n", &sample_doc()).unwrap();
2541 let node_ts: String = conn
2542 .query_row("SELECT created_at FROM nodes WHERE id = 'n'", [], |r| {
2543 r.get(0)
2544 })
2545 .unwrap();
2546 let audit_ts: String = conn
2547 .query_row(
2548 "SELECT timestamp FROM audit_log WHERE node_id = 'n'",
2549 [],
2550 |r| r.get(0),
2551 )
2552 .unwrap();
2553 assert!(is_iso8601_ms_z(&node_ts), "node ts: {node_ts}");
2554 assert!(is_iso8601_ms_z(&audit_ts), "audit ts: {audit_ts}");
2555 }
2556
2557 fn is_iso8601_ms_z(s: &str) -> bool {
2559 let bytes = s.as_bytes();
2560 if bytes.len() != 24 {
2561 return false;
2562 }
2563 let digits =
2579 |range: std::ops::Range<usize>| range.into_iter().all(|i| bytes[i].is_ascii_digit());
2580 digits(0..4)
2581 && bytes[4] == b'-'
2582 && digits(5..7)
2583 && bytes[7] == b'-'
2584 && digits(8..10)
2585 && bytes[10] == b'T'
2586 && digits(11..13)
2587 && bytes[13] == b':'
2588 && digits(14..16)
2589 && bytes[16] == b':'
2590 && digits(17..19)
2591 && bytes[19] == b'.'
2592 && digits(20..23)
2593 && bytes[23] == b'Z'
2594 }
2595
2596 #[test]
2603 fn extract_links_walks_nested_block_positions() {
2604 let inner_link = Inline::Link {
2605 target: "id:nested".into(),
2606 description: Some("nested".into()),
2607 };
2608 let doc = Document {
2609 blocks: vec![
2610 Block::Heading {
2611 level: 1,
2612 title: Title("h".into()),
2613 tags: vec![],
2614 children: vec![Block::Paragraph {
2615 inlines: vec![Inline::Bold(vec![inner_link])],
2616 }],
2617 },
2618 Block::QuoteBlock {
2619 children: vec![Block::Paragraph {
2620 inlines: vec![Inline::Italic(vec![Inline::Link {
2621 target: "id:in_quote".into(),
2622 description: None,
2623 }])],
2624 }],
2625 },
2626 Block::List {
2627 list_type: ListType::Unordered,
2628 items: vec![ListItem {
2629 content: vec![Block::Paragraph {
2630 inlines: vec![Inline::Link {
2631 target: "id:in_list".into(),
2632 description: None,
2633 }],
2634 }],
2635 checkbox: Checkbox::NoCheckbox,
2636 }],
2637 },
2638 Block::Table {
2639 rows: vec![vec![TableCell {
2640 inlines: vec![Inline::Link {
2641 target: "id:in_table".into(),
2642 description: None,
2643 }],
2644 }]],
2645 },
2646 ],
2647 };
2648 let ids: Vec<String> = extract_links(&doc)
2649 .into_iter()
2650 .filter_map(|l| match l {
2651 LinkRef::Id(s) => Some(s),
2652 LinkRef::Name(_) => None,
2653 })
2654 .collect();
2655 assert_eq!(
2656 ids,
2657 vec![
2658 "nested".to_string(),
2659 "in_quote".to_string(),
2660 "in_list".to_string(),
2661 "in_table".to_string(),
2662 ]
2663 );
2664 }
2665
2666 fn empty_doc() -> Document {
2669 Document { blocks: vec![] }
2670 }
2671
2672 #[test]
2673 fn relink_one_inserts_existing_targets() {
2674 let conn = setup();
2675 insert_node(&conn, "src", &empty_doc()).unwrap();
2676 insert_node(&conn, "tgt1", &empty_doc()).unwrap();
2677 insert_node(&conn, "tgt2", &empty_doc()).unwrap();
2678 relink_one(&conn, "src", &link_doc(&["tgt1", "tgt2"])).unwrap();
2679
2680 let mut rows: Vec<(String, String)> = conn
2681 .prepare("SELECT target_id, link_type FROM links WHERE source_id = 'src'")
2682 .unwrap()
2683 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
2684 .unwrap()
2685 .collect::<Result<_, _>>()
2686 .unwrap();
2687 rows.sort();
2688 assert_eq!(
2689 rows,
2690 vec![
2691 ("tgt1".to_string(), "id".to_string()),
2692 ("tgt2".to_string(), "id".to_string()),
2693 ]
2694 );
2695 }
2696
2697 #[test]
2698 fn relink_one_drops_forward_refs() {
2699 let conn = setup();
2700 insert_node(&conn, "src", &empty_doc()).unwrap();
2701 relink_one(&conn, "src", &link_doc(&["tgt-missing"])).unwrap();
2703 let n: i64 = conn
2704 .query_row(
2705 "SELECT COUNT(*) FROM links WHERE source_id = 'src'",
2706 [],
2707 |r| r.get(0),
2708 )
2709 .unwrap();
2710 assert_eq!(n, 0);
2711 }
2712
2713 #[test]
2714 fn relink_one_replaces_prior_outgoing() {
2715 let conn = setup();
2716 insert_node(&conn, "src", &empty_doc()).unwrap();
2717 insert_node(&conn, "old", &empty_doc()).unwrap();
2718 insert_node(&conn, "new", &empty_doc()).unwrap();
2719 relink_one(&conn, "src", &link_doc(&["old"])).unwrap();
2720 relink_one(&conn, "src", &link_doc(&["new"])).unwrap();
2721 let rows: Vec<String> = conn
2722 .prepare("SELECT target_id FROM links WHERE source_id = 'src'")
2723 .unwrap()
2724 .query_map([], |r| r.get::<_, String>(0))
2725 .unwrap()
2726 .collect::<Result<_, _>>()
2727 .unwrap();
2728 assert_eq!(rows, vec!["new".to_string()]);
2729 }
2730
2731 #[test]
2734 fn relink_all_resolves_forward_refs() {
2735 let conn = setup();
2736 insert_node(&conn, "a", &link_doc(&["b"])).unwrap();
2738 insert_node(&conn, "b", &empty_doc()).unwrap();
2739 let n_before: i64 = conn
2741 .query_row(
2742 "SELECT COUNT(*) FROM links WHERE source_id = 'a'",
2743 [],
2744 |r| r.get(0),
2745 )
2746 .unwrap();
2747 assert_eq!(n_before, 0);
2748
2749 let (nodes, links) = relink_all(&conn).unwrap();
2750 assert_eq!(nodes, 2);
2751 assert_eq!(links, 1);
2752 let rows: Vec<String> = conn
2753 .prepare("SELECT target_id FROM links WHERE source_id = 'a'")
2754 .unwrap()
2755 .query_map([], |r| r.get::<_, String>(0))
2756 .unwrap()
2757 .collect::<Result<_, _>>()
2758 .unwrap();
2759 assert_eq!(rows, vec!["b".to_string()]);
2760 }
2761
2762 #[test]
2763 fn relink_all_skips_corrupt_blob() {
2764 let conn = setup();
2765 insert_node(&conn, "good", &empty_doc()).unwrap();
2766 conn.execute(
2768 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) VALUES ('bad', 'x', 'not-a-sexp', '0', '0')",
2769 [],
2770 )
2771 .unwrap();
2772 let (nodes, links) = relink_all(&conn).unwrap();
2773 assert_eq!(nodes, 2);
2774 assert_eq!(links, 0);
2775 }
2776
2777 #[test]
2778 fn relink_all_clears_stale_links_first() {
2779 let conn = setup();
2780 insert_node(&conn, "src", &empty_doc()).unwrap();
2781 insert_node(&conn, "tgt", &empty_doc()).unwrap();
2782 conn.execute(
2784 "INSERT INTO links (source_id, target_id, link_type) VALUES ('src', 'tgt', 'id')",
2785 [],
2786 )
2787 .unwrap();
2788 let (_, links) = relink_all(&conn).unwrap();
2791 assert_eq!(links, 0);
2792 let n: i64 = conn
2793 .query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
2794 .unwrap();
2795 assert_eq!(n, 0);
2796 }
2797
2798 #[test]
2801 fn neighborhood_unknown_id_is_empty() {
2802 let conn = setup();
2803 let n = get_neighborhood(&conn, "nope").unwrap();
2804 assert!(n.outgoing.is_empty());
2805 assert!(n.incoming.is_empty());
2806 }
2807
2808 #[test]
2809 fn neighborhood_returns_outgoing_and_incoming() {
2810 let conn = setup();
2811 insert_node(&conn, "a", &empty_doc()).unwrap();
2812 insert_node(&conn, "b", &empty_doc()).unwrap();
2813 insert_node(&conn, "c", &empty_doc()).unwrap();
2814 relink_one(&conn, "a", &link_doc(&["b"])).unwrap();
2816 relink_one(&conn, "c", &link_doc(&["a"])).unwrap();
2817
2818 let n = get_neighborhood(&conn, "a").unwrap();
2819 assert_eq!(n.outgoing.len(), 1);
2820 assert_eq!(n.outgoing[0].0.0, "b");
2821 assert_eq!(n.outgoing[0].1, LinkType::Id);
2822 assert_eq!(n.incoming.len(), 1);
2823 assert_eq!(n.incoming[0].0.0, "c");
2824 assert_eq!(n.incoming[0].1, LinkType::Id);
2825 }
2826
2827 #[test]
2830 fn list_all_nodes_zero_limit_empty() {
2831 let conn = setup();
2832 insert_node(&conn, "a", &sample_doc()).unwrap();
2833 let v = list_all_nodes(&conn, 0, 0).unwrap();
2834 assert!(v.is_empty());
2835 }
2836
2837 #[test]
2838 fn list_all_nodes_orders_by_updated_at_desc() {
2839 let conn = setup();
2840 insert_node(&conn, "first", &sample_doc()).unwrap();
2841 std::thread::sleep(std::time::Duration::from_millis(10));
2842 insert_node(&conn, "second", &sample_doc()).unwrap();
2843 std::thread::sleep(std::time::Duration::from_millis(10));
2844 insert_node(&conn, "third", &sample_doc()).unwrap();
2845
2846 let v = list_all_nodes(&conn, 10, 0).unwrap();
2847 let ids: Vec<String> = v.iter().map(|(n, _)| n.0.clone()).collect();
2848 assert_eq!(ids, vec!["third", "second", "first"]);
2849 }
2850
2851 #[test]
2852 fn list_all_nodes_paginates() {
2853 let conn = setup();
2854 for i in 0..5 {
2855 insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
2856 std::thread::sleep(std::time::Duration::from_millis(2));
2857 }
2858 let page1 = list_all_nodes(&conn, 2, 0).unwrap();
2859 let page2 = list_all_nodes(&conn, 2, 2).unwrap();
2860 assert_eq!(page1.len(), 2);
2861 assert_eq!(page2.len(), 2);
2862 for (id1, _) in &page1 {
2864 for (id2, _) in &page2 {
2865 assert_ne!(id1.0, id2.0);
2866 }
2867 }
2868 }
2869
2870 #[test]
2873 fn write_path_relinks_on_insert() {
2874 let conn = setup();
2875 insert_node(&conn, "tgt", &empty_doc()).unwrap();
2876 insert_node(&conn, "src", &link_doc(&["tgt"])).unwrap();
2877 let n: i64 = conn
2878 .query_row(
2879 "SELECT COUNT(*) FROM links WHERE source_id = 'src' AND target_id = 'tgt'",
2880 [],
2881 |r| r.get(0),
2882 )
2883 .unwrap();
2884 assert_eq!(n, 1);
2885 }
2886
2887 #[test]
2888 fn write_path_relinks_on_update() {
2889 let conn = setup();
2890 insert_node(&conn, "tgt1", &empty_doc()).unwrap();
2891 insert_node(&conn, "tgt2", &empty_doc()).unwrap();
2892 insert_node(&conn, "src", &link_doc(&["tgt1"])).unwrap();
2893 update_node(&conn, "src", &link_doc(&["tgt2"])).unwrap();
2894 let rows: Vec<String> = conn
2895 .prepare("SELECT target_id FROM links WHERE source_id = 'src'")
2896 .unwrap()
2897 .query_map([], |r| r.get::<_, String>(0))
2898 .unwrap()
2899 .collect::<Result<_, _>>()
2900 .unwrap();
2901 assert_eq!(rows, vec!["tgt2".to_string()]);
2902 }
2903
2904 #[test]
2905 fn write_path_relinks_drop_forward_refs_silently() {
2906 let conn = setup();
2907 insert_node(&conn, "src", &link_doc(&["future"])).unwrap();
2909 let n: i64 = conn
2910 .query_row(
2911 "SELECT COUNT(*) FROM links WHERE source_id = 'src'",
2912 [],
2913 |r| r.get(0),
2914 )
2915 .unwrap();
2916 assert_eq!(n, 0);
2917 insert_node(&conn, "future", &empty_doc()).unwrap();
2919 let (_, links) = relink_all(&conn).unwrap();
2920 assert_eq!(links, 1);
2921 }
2922
2923 #[test]
2926 fn schema_embeddings_table_columns_and_pk() {
2927 let conn = setup();
2928 let cols: Vec<(String, String, i64, i64)> = conn
2929 .prepare("PRAGMA table_info(embeddings)")
2930 .unwrap()
2931 .query_map([], |r| {
2932 Ok((
2933 r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(5)?, ))
2938 })
2939 .unwrap()
2940 .collect::<Result<_, _>>()
2941 .unwrap();
2942 assert_eq!(
2943 cols,
2944 vec![
2945 ("node_id".into(), "TEXT".into(), 1, 1),
2946 ("model".into(), "TEXT".into(), 1, 2),
2947 ("embedding".into(), "BLOB".into(), 1, 0),
2948 ]
2949 );
2950 }
2951
2952 #[test]
2953 fn schema_embeddings_table_foreign_keys() {
2954 let conn = setup();
2955 let fks: Vec<(String, String, String, String)> = conn
2956 .prepare("PRAGMA foreign_key_list(embeddings)")
2957 .unwrap()
2958 .query_map([], |r| {
2959 Ok((
2960 r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?, r.get::<_, String>(6)?, ))
2965 })
2966 .unwrap()
2967 .collect::<Result<_, _>>()
2968 .unwrap();
2969 assert_eq!(fks.len(), 1);
2970 let (table, from, to, on_delete) = &fks[0];
2971 assert_eq!(table, "nodes");
2972 assert_eq!(from, "node_id");
2973 assert_eq!(to, "id");
2974 assert_eq!(on_delete, "CASCADE");
2975 }
2976
2977 #[test]
2978 fn schema_embeddings_model_index_present() {
2979 let conn = setup();
2980 let n: i64 = conn
2981 .query_row(
2982 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='embeddings_model_idx' AND tbl_name='embeddings'",
2983 [],
2984 |r| r.get(0),
2985 )
2986 .unwrap();
2987 assert_eq!(n, 1);
2988 }
2989
2990 #[test]
2991 fn schema_embeddings_version_unchanged() {
2992 let conn = setup();
2995 let v: i64 = conn
2996 .query_row("PRAGMA user_version", [], |r| r.get(0))
2997 .unwrap();
2998 assert_eq!(v, i64::from(CURRENT_SCHEMA_VERSION));
2999 }
3000
3001 #[test]
3002 fn schema_embeddings_table_cascades_on_node_delete() {
3003 let conn = setup();
3004 insert_node(&conn, "n", &sample_doc()).unwrap();
3005 conn.execute(
3006 "INSERT INTO embeddings (node_id, model, embedding) VALUES ('n', 'm', X'00000000')",
3007 [],
3008 )
3009 .unwrap();
3010 delete_node(&conn, "n").unwrap();
3011 let n: i64 = conn
3012 .query_row(
3013 "SELECT COUNT(*) FROM embeddings WHERE node_id = 'n'",
3014 [],
3015 |r| r.get(0),
3016 )
3017 .unwrap();
3018 assert_eq!(n, 0, "FOREIGN KEY ON DELETE CASCADE must clear embeddings");
3019 }
3020
3021 #[test]
3024 fn cosine_similarity_identical_vectors_is_one() {
3025 let v = vec![1.0_f32, 2.0, 3.0];
3026 let s = cosine_similarity(&v, &v);
3027 assert!((s - 1.0).abs() < 1e-6, "got {s}");
3028 }
3029
3030 #[test]
3031 fn cosine_similarity_orthogonal_vectors_is_zero() {
3032 let s = cosine_similarity(&[1.0_f32, 0.0], &[0.0, 1.0]);
3033 assert!(s.abs() < 1e-6, "got {s}");
3034 }
3035
3036 #[test]
3037 fn cosine_similarity_opposite_vectors_is_negative_one() {
3038 let s = cosine_similarity(&[1.0_f32, 0.0], &[-1.0, 0.0]);
3039 assert!((s + 1.0).abs() < 1e-6, "got {s}");
3040 }
3041
3042 #[test]
3043 fn cosine_similarity_length_mismatch_returns_zero() {
3044 let s = cosine_similarity(&[1.0_f32, 2.0], &[1.0, 2.0, 3.0]);
3045 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3046 }
3047
3048 #[test]
3049 fn cosine_similarity_zero_norm_returns_zero() {
3050 let s = cosine_similarity(&[0.0_f32, 0.0, 0.0], &[1.0, 2.0, 3.0]);
3051 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3052 let s = cosine_similarity(&[1.0_f32, 2.0], &[0.0, 0.0]);
3053 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3054 }
3055
3056 #[test]
3057 fn cosine_similarity_empty_vectors_is_zero() {
3058 let s = cosine_similarity(&[], &[]);
3060 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3061 }
3062
3063 #[test]
3066 fn rrf_empty_lists_yields_empty() {
3067 let out = reciprocal_rank_fusion(60, &[]);
3068 assert!(out.is_empty());
3069 let out = reciprocal_rank_fusion(60, &[Vec::<NodeId>::new(), Vec::<NodeId>::new()]);
3070 assert!(out.is_empty());
3071 }
3072
3073 #[test]
3074 fn rrf_single_list_preserves_order() {
3075 let l = vec![NodeId("a".into()), NodeId("b".into()), NodeId("c".into())];
3076 let out = reciprocal_rank_fusion(60, std::slice::from_ref(&l));
3077 assert_eq!(out, l);
3078 }
3079
3080 #[test]
3081 fn rrf_two_lists_combine_scores() {
3082 let l1 = vec![NodeId("a".into()), NodeId("b".into())];
3083 let l2 = vec![NodeId("b".into()), NodeId("a".into())];
3084 let out = reciprocal_rank_fusion(60, &[l1, l2]);
3089 assert_eq!(out, vec![NodeId("a".into()), NodeId("b".into())]);
3090 }
3091
3092 #[test]
3093 fn rrf_node_only_in_one_list_ranks_lower() {
3094 let l1 = vec![NodeId("a".into()), NodeId("b".into())];
3096 let l2 = vec![NodeId("b".into())];
3097 let out = reciprocal_rank_fusion(60, &[l1, l2]);
3101 assert_eq!(out, vec![NodeId("b".into()), NodeId("a".into())]);
3102 }
3103
3104 #[test]
3105 fn rrf_ties_break_by_ascending_id() {
3106 let l1 = vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())];
3107 let l2 = vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())];
3108 let out = reciprocal_rank_fusion(60, &[l1, l2]);
3116 assert_eq!(
3117 out,
3118 vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())]
3119 );
3120 }
3121
3122 #[test]
3123 fn rrf_truly_tied_scores_break_by_ascending_id() {
3124 let l1 = vec![NodeId("z".into())]; let l2 = vec![NodeId("a".into())]; let out = reciprocal_rank_fusion(60, &[l1, l2]);
3130 assert_eq!(out, vec![NodeId("a".into()), NodeId("z".into())]);
3132 }
3133
3134 #[test]
3137 fn vec_extension_missing_path_returns_err() {
3138 let conn = setup();
3139 let err = try_load_vec_extension(&conn, "/nonexistent/path/to/sqlite-vec.dylib")
3140 .expect_err("loading a missing path must fail");
3141 assert!(matches!(err, VecExtensionError::LoadExtension(_)));
3142 }
3143
3144 #[test]
3145 fn vec_extension_failure_leaves_connection_usable() {
3146 let conn = setup();
3147 let _ = try_load_vec_extension(&conn, "/nonexistent/path/to/sqlite-vec.dylib");
3148 insert_node(&conn, "after-fail", &sample_doc()).unwrap();
3150 let doc = get_node(&conn, "after-fail").unwrap().unwrap();
3151 assert_eq!(doc, sample_doc());
3152 }
3153
3154 fn count_embeddings(conn: &Connection, model: &str) -> i64 {
3157 conn.query_row(
3158 "SELECT COUNT(*) FROM embeddings WHERE model = ?1",
3159 params![model],
3160 |r| r.get(0),
3161 )
3162 .unwrap()
3163 }
3164
3165 #[test]
3170 fn storage_precomputed_embedding_inserts_one_row_on_insert() {
3171 let conn = setup();
3172 insert_node_with(
3173 &conn,
3174 "n",
3175 &sample_doc(),
3176 Some(vec![1.0, 2.0, 3.0]),
3177 Some("test-model"),
3178 )
3179 .unwrap();
3180 assert_eq!(count_embeddings(&conn, "test-model"), 1);
3181 let blob: Vec<u8> = conn
3182 .query_row(
3183 "SELECT embedding FROM embeddings WHERE node_id = 'n' AND model = 'test-model'",
3184 [],
3185 |r| r.get(0),
3186 )
3187 .unwrap();
3188 let decoded = embedding::decode_embedding(&blob).unwrap();
3189 assert_eq!(decoded, vec![1.0_f32, 2.0, 3.0]);
3190 }
3191
3192 #[test]
3193 fn storage_precomputed_embedding_replaces_on_update() {
3194 let conn = setup();
3195 insert_node_with(
3196 &conn,
3197 "n",
3198 &sample_doc(),
3199 Some(vec![1.0, 0.0]),
3200 Some("test-model"),
3201 )
3202 .unwrap();
3203 let mut doc = sample_doc();
3204 doc.blocks.push(Block::Paragraph {
3205 inlines: vec![Inline::Plain("more".into())],
3206 });
3207 update_node_with(&conn, "n", &doc, Some(vec![0.0, 1.0]), Some("test-model")).unwrap();
3208 assert_eq!(count_embeddings(&conn, "test-model"), 1);
3209 let blob: Vec<u8> = conn
3210 .query_row(
3211 "SELECT embedding FROM embeddings WHERE node_id = 'n'",
3212 [],
3213 |r| r.get(0),
3214 )
3215 .unwrap();
3216 assert_eq!(
3217 embedding::decode_embedding(&blob).unwrap(),
3218 vec![0.0_f32, 1.0]
3219 );
3220 }
3221
3222 #[test]
3223 fn storage_precomputed_embedding_skipped_without_vector() {
3224 let conn = setup();
3225 insert_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3226 update_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3227 assert_eq!(
3228 conn.query_row::<i64, _, _>("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3229 .unwrap(),
3230 0
3231 );
3232 }
3233
3234 #[test]
3235 fn storage_precomputed_embedding_skipped_without_model() {
3236 let conn = setup();
3239 insert_node_with(&conn, "n", &sample_doc(), Some(vec![1.0]), None).unwrap();
3240 assert_eq!(
3241 conn.query_row::<i64, _, _>("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3242 .unwrap(),
3243 0
3244 );
3245 }
3246
3247 #[test]
3248 fn storage_precomputed_embedding_keys_by_node_and_model() {
3249 let conn = setup();
3250 insert_node_with(
3251 &conn,
3252 "n",
3253 &sample_doc(),
3254 Some(vec![1.0, 0.0]),
3255 Some("model-a"),
3256 )
3257 .unwrap();
3258 update_node_with(
3259 &conn,
3260 "n",
3261 &sample_doc(),
3262 Some(vec![0.0, 1.0]),
3263 Some("model-b"),
3264 )
3265 .unwrap();
3266 assert_eq!(count_embeddings(&conn, "model-a"), 1);
3267 assert_eq!(count_embeddings(&conn, "model-b"), 1);
3268 }
3269
3270 #[test]
3273 fn search_hybrid_no_query_embedding_matches_search_fts() {
3274 let conn = setup();
3275 let doc1 = Document {
3276 blocks: vec![Block::Heading {
3277 level: 1,
3278 title: Title("Rust".into()),
3279 tags: vec![],
3280 children: vec![Block::Paragraph {
3281 inlines: vec![Inline::Plain("systems language".into())],
3282 }],
3283 }],
3284 };
3285 let doc2 = Document {
3286 blocks: vec![Block::Heading {
3287 level: 1,
3288 title: Title("Python".into()),
3289 tags: vec![],
3290 children: vec![Block::Paragraph {
3291 inlines: vec![Inline::Plain("scripting language".into())],
3292 }],
3293 }],
3294 };
3295 insert_node(&conn, "a", &doc1).unwrap();
3296 insert_node(&conn, "b", &doc2).unwrap();
3297 let fts: Vec<NodeId> = search_fts(&conn, "language")
3298 .unwrap()
3299 .into_iter()
3300 .map(NodeId)
3301 .collect();
3302 let hybrid = search_hybrid(&conn, "language", None).unwrap();
3303 assert_eq!(hybrid, fts);
3304 }
3305
3306 #[test]
3307 fn search_hybrid_empty_query_yields_empty() {
3308 let conn = setup();
3309 insert_node(&conn, "a", &sample_doc()).unwrap();
3310 assert!(search_hybrid(&conn, "", None).unwrap().is_empty());
3312 assert!(
3315 search_hybrid(&conn, " ", Some((vec![1.0], "stub")))
3316 .unwrap()
3317 .is_empty()
3318 );
3319 }
3320
3321 #[test]
3322 fn search_hybrid_with_query_embedding_combines_keyword_and_vector_rankings() {
3323 let conn = setup();
3324 insert_node(
3325 &conn,
3326 "alpha",
3327 &Document {
3328 blocks: vec![Block::Heading {
3329 level: 1,
3330 title: Title("alpha note".into()),
3331 tags: vec![],
3332 children: vec![Block::Paragraph {
3333 inlines: vec![Inline::Plain("the quick brown fox".into())],
3334 }],
3335 }],
3336 },
3337 )
3338 .unwrap();
3339 insert_node(
3340 &conn,
3341 "beta",
3342 &Document {
3343 blocks: vec![Block::Heading {
3344 level: 1,
3345 title: Title("beta note".into()),
3346 tags: vec![],
3347 children: vec![Block::Paragraph {
3348 inlines: vec![Inline::Plain("a different idea entirely".into())],
3349 }],
3350 }],
3351 },
3352 )
3353 .unwrap();
3354 let alpha_vec: Vec<f32> = vec![1.0, 0.0];
3355 let beta_vec: Vec<f32> = vec![0.0, 1.0];
3356 for (id, v) in [("alpha", &alpha_vec), ("beta", &beta_vec)] {
3357 conn.execute(
3358 "INSERT INTO embeddings (node_id, model, embedding) VALUES (?1, 'hybrid-model', ?2)",
3359 params![id, embedding::encode_embedding(v)],
3360 )
3361 .unwrap();
3362 }
3363 let out =
3366 search_hybrid(&conn, "alpha", Some((vec![0.0_f32, 1.0], "hybrid-model"))).unwrap();
3367 assert_eq!(out, vec![NodeId("alpha".into()), NodeId("beta".into())]);
3373 }
3374
3375 #[test]
3376 fn search_hybrid_with_query_embedding_returns_vector_only_when_fts_empty() {
3377 let conn = setup();
3378 insert_node(&conn, "a", &sample_doc()).unwrap();
3379 conn.execute(
3380 "INSERT INTO embeddings (node_id, model, embedding) VALUES ('a', 'm', ?1)",
3381 params![embedding::encode_embedding(&[1.0_f32])],
3382 )
3383 .unwrap();
3384 let out = search_hybrid(&conn, "nomatch", Some((vec![1.0_f32], "m"))).unwrap();
3387 assert_eq!(out, vec![NodeId("a".into())]);
3388 }
3389
3390 #[test]
3393 fn embedding_disabled_writes_succeed() {
3394 let conn = setup();
3395 insert_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3396 update_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3397 delete_node(&conn, "n").unwrap();
3398 assert!(get_node(&conn, "n").unwrap().is_none());
3399 }
3400
3401 #[test]
3402 fn embedding_disabled_keeps_embeddings_table_empty() {
3403 let conn = setup();
3404 insert_node(&conn, "a", &sample_doc()).unwrap();
3405 insert_node(&conn, "b", &sample_doc()).unwrap();
3406 let n: i64 = conn
3407 .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3408 .unwrap();
3409 assert_eq!(n, 0);
3410 }
3411
3412 #[test]
3413 fn embedding_disabled_search_hybrid_degrades_to_search_fts() {
3414 let conn = setup();
3415 let doc = Document {
3416 blocks: vec![Block::Heading {
3417 level: 1,
3418 title: Title("hello".into()),
3419 tags: vec![],
3420 children: vec![Block::Paragraph {
3421 inlines: vec![Inline::Plain("world".into())],
3422 }],
3423 }],
3424 };
3425 insert_node(&conn, "a", &doc).unwrap();
3426 let fts: Vec<NodeId> = search_fts(&conn, "hello")
3427 .unwrap()
3428 .into_iter()
3429 .map(NodeId)
3430 .collect();
3431 let hybrid = search_hybrid(&conn, "hello", None).unwrap();
3432 assert_eq!(hybrid, fts);
3433 }
3434
3435 fn named_doc(slug: &str, body: &str) -> Document {
3439 Document {
3440 blocks: vec![
3441 Block::Keyword {
3442 name: "name".into(),
3443 value: format!(" {slug}"),
3444 },
3445 Block::Paragraph {
3446 inlines: vec![Inline::Plain(body.into())],
3447 },
3448 ],
3449 }
3450 }
3451
3452 fn referencing_doc(slugs: &[&str]) -> Document {
3454 let inlines: Vec<Inline> = slugs
3455 .iter()
3456 .map(|s| Inline::Link {
3457 target: (*s).to_string(),
3458 description: None,
3459 })
3460 .collect();
3461 Document {
3462 blocks: vec![Block::Paragraph { inlines }],
3463 }
3464 }
3465
3466 fn count_links_total(conn: &Connection) -> i64 {
3467 conn.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
3468 .unwrap()
3469 }
3470
3471 fn fetch_name_link(conn: &Connection, src_id: &str, slug: &str) -> Option<LinkRow> {
3473 conn.query_row(
3474 "SELECT source_id, link_type, target_id, target_slug FROM links \
3475 WHERE source_id = ?1 AND link_type = 'name' AND target_slug = ?2",
3476 params![src_id, slug],
3477 |r| {
3478 Ok(LinkRow {
3479 source_id: r.get(0)?,
3480 link_type: r.get(1)?,
3481 target_id: r.get(2)?,
3482 target_slug: r.get(3)?,
3483 })
3484 },
3485 )
3486 .optional()
3487 .unwrap()
3488 }
3489
3490 #[test]
3494 fn links_table_unified_schema() {
3495 let conn = setup();
3496 let n_links: i64 = conn
3498 .query_row(
3499 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='links'",
3500 [],
3501 |r| r.get(0),
3502 )
3503 .unwrap();
3504 assert_eq!(n_links, 1);
3505 let n_name_links: i64 = conn
3506 .query_row(
3507 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
3508 [],
3509 |r| r.get(0),
3510 )
3511 .unwrap();
3512 assert_eq!(n_name_links, 0, "name_links table must not exist");
3513
3514 let n_view: i64 = conn
3516 .query_row(
3517 "SELECT COUNT(*) FROM sqlite_master WHERE type='view' AND name='name_links'",
3518 [],
3519 |r| r.get(0),
3520 )
3521 .unwrap();
3522 assert_eq!(n_view, 0, "name_links view must not exist");
3523
3524 let cols: Vec<(String, String, i64, i64)> = conn
3526 .prepare("PRAGMA table_info(links)")
3527 .unwrap()
3528 .query_map([], |r| {
3529 Ok((
3530 r.get::<_, String>(1)?,
3531 r.get::<_, String>(2)?,
3532 r.get::<_, i64>(3)?,
3533 r.get::<_, i64>(5)?,
3534 ))
3535 })
3536 .unwrap()
3537 .collect::<Result<_, _>>()
3538 .unwrap();
3539 assert_eq!(
3540 cols,
3541 vec![
3542 ("source_id".into(), "TEXT".into(), 1, 1),
3543 ("link_type".into(), "TEXT".into(), 1, 2),
3544 ("target_id".into(), "TEXT".into(), 0, 3),
3545 ("target_slug".into(), "TEXT".into(), 0, 4),
3546 ]
3547 );
3548
3549 insert_node(&conn, "src", &empty_doc()).unwrap();
3551 for sql in [
3552 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3554 VALUES ('src', 'id', NULL, NULL)",
3555 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3557 VALUES ('src', 'id', 'src', 'foo')",
3558 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3560 VALUES ('src', 'name', NULL, NULL)",
3561 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3563 VALUES ('src', 'other', 'src', NULL)",
3564 ] {
3565 assert!(
3566 conn.execute(sql, []).is_err(),
3567 "expected CHECK rejection: {sql}"
3568 );
3569 }
3570
3571 insert_node(&conn, "named", &named_doc("alpha", "x")).unwrap();
3573 let mut doc_with_both = referencing_doc(&["alpha"]);
3574 doc_with_both.blocks.push(Block::Paragraph {
3575 inlines: vec![Inline::Link {
3576 target: "id:src".into(),
3577 description: None,
3578 }],
3579 });
3580 insert_node(&conn, "mixed", &doc_with_both).unwrap();
3581 let rows: Vec<(String, String, Option<String>, Option<String>)> = conn
3582 .prepare(
3583 "SELECT source_id, link_type, target_id, target_slug FROM links \
3584 WHERE source_id = 'mixed' ORDER BY link_type",
3585 )
3586 .unwrap()
3587 .query_map([], |r| {
3588 Ok((
3589 r.get::<_, String>(0)?,
3590 r.get::<_, String>(1)?,
3591 r.get::<_, Option<String>>(2)?,
3592 r.get::<_, Option<String>>(3)?,
3593 ))
3594 })
3595 .unwrap()
3596 .collect::<Result<_, _>>()
3597 .unwrap();
3598 assert_eq!(
3599 rows,
3600 vec![
3601 ("mixed".into(), "id".into(), Some("src".into()), None),
3602 (
3603 "mixed".into(),
3604 "name".into(),
3605 Some("named".into()),
3606 Some("alpha".into())
3607 ),
3608 ]
3609 );
3610 }
3611
3612 #[test]
3614 fn id_link_semantics_preserved() {
3615 let doc = link_doc(&["aaa", "bbb", "aaa"]);
3617 let ids: Vec<String> = extract_links(&doc)
3618 .into_iter()
3619 .filter_map(|l| match l {
3620 LinkRef::Id(s) => Some(s),
3621 LinkRef::Name(_) => None,
3622 })
3623 .collect();
3624 assert_eq!(ids, vec!["aaa".to_string(), "bbb".to_string()]);
3625
3626 let conn = setup();
3628 insert_node(&conn, "tgt", &empty_doc()).unwrap();
3629 insert_node(&conn, "src", &link_doc(&["tgt"])).unwrap();
3630 let row: (Option<String>, String, Option<String>) = conn
3631 .query_row(
3632 "SELECT target_id, link_type, target_slug FROM links WHERE source_id = 'src'",
3633 [],
3634 |r| {
3635 Ok((
3636 r.get::<_, Option<String>>(0)?,
3637 r.get::<_, String>(1)?,
3638 r.get::<_, Option<String>>(2)?,
3639 ))
3640 },
3641 )
3642 .unwrap();
3643 assert_eq!(row, (Some("tgt".into()), "id".into(), None));
3644
3645 insert_node(&conn, "fwd", &link_doc(&["missing-tgt"])).unwrap();
3647 let n: i64 = conn
3648 .query_row(
3649 "SELECT COUNT(*) FROM links WHERE source_id = 'fwd'",
3650 [],
3651 |r| r.get(0),
3652 )
3653 .unwrap();
3654 assert_eq!(n, 0);
3655
3656 let n = get_neighborhood(&conn, "src").unwrap();
3658 assert_eq!(n.outgoing, vec![(NodeId("tgt".into()), LinkType::Id)]);
3659 let n = get_neighborhood(&conn, "tgt").unwrap();
3660 assert_eq!(n.incoming, vec![(NodeId("src".into()), LinkType::Id)]);
3661
3662 insert_node(&conn, "tgt2", &empty_doc()).unwrap();
3664 update_node(&conn, "src", &link_doc(&["tgt2"])).unwrap();
3665 let n = get_neighborhood(&conn, "src").unwrap();
3666 assert_eq!(n.outgoing, vec![(NodeId("tgt2".into()), LinkType::Id)]);
3667
3668 insert_node(&conn, "future-src", &link_doc(&["future-tgt"])).unwrap();
3670 insert_node(&conn, "future-tgt", &empty_doc()).unwrap();
3671 let (_, links) = relink_all(&conn).unwrap();
3672 assert!(links >= 1);
3673 let row: Option<String> = conn
3674 .query_row(
3675 "SELECT target_id FROM links \
3676 WHERE source_id = 'future-src' AND link_type = 'id'",
3677 [],
3678 |r| r.get(0),
3679 )
3680 .optional()
3681 .unwrap()
3682 .flatten();
3683 assert_eq!(row.as_deref(), Some("future-tgt"));
3684
3685 delete_node(&conn, "future-tgt").unwrap();
3688 let n: i64 = conn
3689 .query_row(
3690 "SELECT COUNT(*) FROM links \
3691 WHERE source_id = 'future-src' AND link_type = 'id'",
3692 [],
3693 |r| r.get(0),
3694 )
3695 .unwrap();
3696 assert_eq!(n, 0);
3697 }
3698
3699 #[test]
3702 fn name_link_broken_and_reverse_resolve_preserved() {
3703 let conn = setup();
3704
3705 insert_node(&conn, "src1", &referencing_doc(&["future"])).unwrap();
3707 let row = fetch_name_link(&conn, "src1", "future").unwrap();
3708 assert_eq!(row.link_type, LinkType::Name);
3709 assert_eq!(row.target_slug.as_deref(), Some("future"));
3710 assert!(row.target_id.is_none());
3711
3712 insert_node(&conn, "named", &named_doc("alpha", "I am alpha")).unwrap();
3714 insert_node(&conn, "src2", &referencing_doc(&["alpha"])).unwrap();
3715 let row = fetch_name_link(&conn, "src2", "alpha").unwrap();
3716 assert_eq!(row.target_id.as_deref(), Some("named"));
3717
3718 insert_node(&conn, "src3", &referencing_doc(&["beta"])).unwrap();
3720 assert!(
3721 fetch_name_link(&conn, "src3", "beta")
3722 .unwrap()
3723 .target_id
3724 .is_none()
3725 );
3726 insert_node(&conn, "beta-target", &named_doc("beta", "x")).unwrap();
3727 let row = fetch_name_link(&conn, "src3", "beta").unwrap();
3728 assert_eq!(row.target_id.as_deref(), Some("beta-target"));
3729
3730 insert_node(&conn, "src4", &referencing_doc(&["gamma"])).unwrap();
3732 insert_node(
3733 &conn,
3734 "gamma-target",
3735 &Document {
3736 blocks: vec![Block::Paragraph {
3737 inlines: vec![Inline::Plain("unnamed".into())],
3738 }],
3739 },
3740 )
3741 .unwrap();
3742 assert!(
3743 fetch_name_link(&conn, "src4", "gamma")
3744 .unwrap()
3745 .target_id
3746 .is_none()
3747 );
3748 update_node(&conn, "gamma-target", &named_doc("gamma", "x")).unwrap();
3749 let row = fetch_name_link(&conn, "src4", "gamma").unwrap();
3750 assert_eq!(row.target_id.as_deref(), Some("gamma-target"));
3751
3752 update_node(&conn, "src2", &referencing_doc(&["beta"])).unwrap();
3754 assert!(fetch_name_link(&conn, "src2", "alpha").is_none());
3755 let row = fetch_name_link(&conn, "src2", "beta").unwrap();
3756 assert_eq!(row.target_id.as_deref(), Some("beta-target"));
3757
3758 insert_node(&conn, "src5", &referencing_doc(&["missing-1", "missing-2"])).unwrap();
3760 let broken = list_broken_links(&conn).unwrap();
3761 let slugs: Vec<&str> = broken
3762 .iter()
3763 .map(|r| r.target_slug.as_deref().unwrap_or(""))
3764 .collect();
3765 assert!(slugs.contains(&"missing-1"));
3766 assert!(slugs.contains(&"missing-2"));
3767 for row in &broken {
3768 assert_eq!(row.link_type, LinkType::Name);
3769 assert!(row.target_id.is_none());
3770 }
3771 }
3772
3773 #[test]
3776 fn name_link_demote_preserved() {
3777 let conn = setup();
3778 insert_node(&conn, "target", &named_doc("alpha", "x")).unwrap();
3779 insert_node(&conn, "source", &referencing_doc(&["alpha"])).unwrap();
3780 assert_eq!(
3781 fetch_name_link(&conn, "source", "alpha")
3782 .unwrap()
3783 .target_id
3784 .as_deref(),
3785 Some("target")
3786 );
3787
3788 update_node(&conn, "target", &named_doc("renamed", "x")).unwrap();
3790 let row = fetch_name_link(&conn, "source", "alpha").unwrap();
3791 assert!(row.target_id.is_none(), "slug change must demote to broken");
3792 assert_eq!(row.target_slug.as_deref(), Some("alpha"));
3793
3794 update_node(&conn, "target", &named_doc("alpha", "x")).unwrap();
3797 assert_eq!(
3798 fetch_name_link(&conn, "source", "alpha")
3799 .unwrap()
3800 .target_id
3801 .as_deref(),
3802 Some("target")
3803 );
3804 delete_node(&conn, "target").unwrap();
3805 let row = fetch_name_link(&conn, "source", "alpha").unwrap();
3806 assert!(row.target_id.is_none(), "delete must demote to broken");
3807 assert_eq!(row.target_slug.as_deref(), Some("alpha"));
3808
3809 delete_node(&conn, "source").unwrap();
3811 assert_eq!(count_links_total(&conn), 0);
3812 }
3813
3814 #[test]
3816 fn migration_from_split_tables() {
3817 let conn = Connection::open_in_memory().unwrap();
3821 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
3822 conn.execute(NODES_TABLE, []).unwrap();
3823 conn.execute(NODE_TAGS_TABLE, []).unwrap();
3824 conn.execute(
3826 "CREATE TABLE links ( \
3827 source_id TEXT NOT NULL, \
3828 target_id TEXT NOT NULL, \
3829 link_type TEXT NOT NULL, \
3830 PRIMARY KEY (source_id, target_id, link_type), \
3831 FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE, \
3832 FOREIGN KEY (target_id) REFERENCES nodes(id) ON DELETE CASCADE)",
3833 [],
3834 )
3835 .unwrap();
3836 conn.execute(
3837 "CREATE TABLE name_links ( \
3838 src_id TEXT NOT NULL, \
3839 dst_slug TEXT NOT NULL, \
3840 dst_id TEXT, \
3841 PRIMARY KEY (src_id, dst_slug), \
3842 FOREIGN KEY (src_id) REFERENCES nodes(id) ON DELETE CASCADE)",
3843 [],
3844 )
3845 .unwrap();
3846 for nid in ["a", "b", "c", "d"] {
3848 conn.execute(
3849 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) \
3850 VALUES (?1, 't', ?2, '0', '0')",
3851 params![nid, sexp::encode_document(&Document { blocks: vec![] })],
3852 )
3853 .unwrap();
3854 }
3855 conn.execute(
3857 "INSERT INTO links (source_id, target_id, link_type) VALUES ('a', 'b', 'id')",
3858 [],
3859 )
3860 .unwrap();
3861 conn.execute(
3863 "INSERT INTO name_links (src_id, dst_slug, dst_id) VALUES ('c', 'beta', 'b')",
3864 [],
3865 )
3866 .unwrap();
3867 conn.execute(
3869 "INSERT INTO name_links (src_id, dst_slug, dst_id) VALUES ('d', 'ghost', NULL)",
3870 [],
3871 )
3872 .unwrap();
3873
3874 init_db(&conn).unwrap();
3876
3877 let n_name_links: i64 = conn
3879 .query_row(
3880 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
3881 [],
3882 |r| r.get(0),
3883 )
3884 .unwrap();
3885 assert_eq!(n_name_links, 0);
3886
3887 let mut rows: Vec<(String, String, Option<String>, Option<String>)> = conn
3889 .prepare(
3890 "SELECT source_id, link_type, target_id, target_slug FROM links \
3891 ORDER BY source_id",
3892 )
3893 .unwrap()
3894 .query_map([], |r| {
3895 Ok((
3896 r.get::<_, String>(0)?,
3897 r.get::<_, String>(1)?,
3898 r.get::<_, Option<String>>(2)?,
3899 r.get::<_, Option<String>>(3)?,
3900 ))
3901 })
3902 .unwrap()
3903 .collect::<Result<_, _>>()
3904 .unwrap();
3905 rows.sort();
3906 assert_eq!(
3907 rows,
3908 vec![
3909 ("a".into(), "id".into(), Some("b".into()), None),
3910 (
3911 "c".into(),
3912 "name".into(),
3913 Some("b".into()),
3914 Some("beta".into())
3915 ),
3916 ("d".into(), "name".into(), None, Some("ghost".into())),
3917 ]
3918 );
3919
3920 init_db(&conn).unwrap();
3922 let n: i64 = count_links_total(&conn);
3923 assert_eq!(n, 3);
3924 }
3925
3926 #[test]
3927 fn link_parser_extracts_bracket_name_refs() {
3928 let doc = Document {
3929 blocks: vec![Block::Paragraph {
3930 inlines: vec![
3931 Inline::Plain("see ".into()),
3932 Inline::Link {
3933 target: "alpha".into(),
3934 description: None,
3935 },
3936 Inline::Plain(" and ".into()),
3937 Inline::Link {
3938 target: "beta".into(),
3939 description: None,
3940 },
3941 ],
3942 }],
3943 };
3944 let slugs: Vec<String> = extract_links(&doc)
3945 .into_iter()
3946 .filter_map(|l| match l {
3947 LinkRef::Name(s) => Some(s),
3948 LinkRef::Id(_) => None,
3949 })
3950 .collect();
3951 assert_eq!(slugs, vec!["alpha".to_string(), "beta".to_string()]);
3952 }
3953
3954 #[test]
3955 fn link_parser_extracts_dedupes_preserving_first_seen_order() {
3956 let doc = referencing_doc(&["alpha", "beta", "alpha", "gamma"]);
3957 let slugs: Vec<String> = extract_links(&doc)
3958 .into_iter()
3959 .filter_map(|l| match l {
3960 LinkRef::Name(s) => Some(s),
3961 LinkRef::Id(_) => None,
3962 })
3963 .collect();
3964 assert_eq!(slugs, vec!["alpha", "beta", "gamma"]);
3965 }
3966
3967 #[test]
3968 fn link_parser_extracts_walks_nested_inline_positions() {
3969 let doc = Document {
3970 blocks: vec![Block::Paragraph {
3971 inlines: vec![Inline::Bold(vec![Inline::Italic(vec![Inline::Link {
3972 target: "nested".into(),
3973 description: None,
3974 }])])],
3975 }],
3976 };
3977 let slugs: Vec<String> = extract_links(&doc)
3978 .into_iter()
3979 .filter_map(|l| match l {
3980 LinkRef::Name(s) => Some(s),
3981 LinkRef::Id(_) => None,
3982 })
3983 .collect();
3984 assert_eq!(slugs, vec!["nested".to_string()]);
3985 }
3986
3987 #[test]
3988 fn link_parser_extracts_ignores_scheme_links() {
3989 let doc = Document {
3990 blocks: vec![Block::Paragraph {
3991 inlines: vec![
3992 Inline::Link {
3993 target: "id:abc-123".into(),
3994 description: None,
3995 },
3996 Inline::Link {
3997 target: "https://example.com".into(),
3998 description: Some("ex".into()),
3999 },
4000 Inline::Link {
4001 target: "keep".into(),
4002 description: None,
4003 },
4004 Inline::Link {
4005 target: "alpha".into(),
4006 description: Some("Alpha".into()),
4007 },
4008 ],
4009 }],
4010 };
4011 let slugs: Vec<String> = extract_links(&doc)
4012 .into_iter()
4013 .filter_map(|l| match l {
4014 LinkRef::Name(s) => Some(s),
4015 LinkRef::Id(_) => None,
4016 })
4017 .collect();
4018 assert_eq!(slugs, vec!["keep".to_string()]);
4019 }
4020
4021 #[test]
4022 fn parser_respects_literal_regions_for_bracket_refs() {
4023 let doc = Document {
4024 blocks: vec![
4025 Block::Paragraph {
4026 inlines: vec![
4027 Inline::Plain("Use ".into()),
4028 Inline::Verbatim("[[verbatim-target]]".into()),
4029 Inline::Plain(" for verbatim, ".into()),
4030 Inline::InlineCode("[[code-target]]".into()),
4031 Inline::Plain(" for inline code.".into()),
4032 ],
4033 },
4034 Block::SrcBlock {
4035 language: "org".into(),
4036 content: "Example: [[src-block-target]]\n".into(),
4037 },
4038 Block::ExampleBlock {
4039 content: "Example: [[example-block-target]]\n".into(),
4040 },
4041 Block::Paragraph {
4042 inlines: vec![Inline::Link {
4043 target: "real-target".into(),
4044 description: None,
4045 }],
4046 },
4047 ],
4048 };
4049 let slugs: Vec<String> = extract_links(&doc)
4050 .into_iter()
4051 .filter_map(|l| match l {
4052 LinkRef::Name(s) => Some(s),
4053 LinkRef::Id(_) => None,
4054 })
4055 .collect();
4056 assert_eq!(slugs, vec!["real-target".to_string()]);
4057 }
4058
4059 #[test]
4060 fn parser_respects_literal_regions_round_trip_via_org_parser() {
4061 let org = "Use =[[verbatim-target]]= for verbatim, \
4062 ~[[code-target]]~ for inline code.\n\
4063 #+begin_src org\n\
4064 Example: [[src-block-target]]\n\
4065 #+end_src\n\
4066 #+begin_example\n\
4067 Example: [[example-block-target]]\n\
4068 #+end_example\n\
4069 [[real-target]]\n";
4070 let doc = crate::parser::parse_document(org).unwrap();
4071 let slugs: Vec<String> = extract_links(&doc)
4072 .into_iter()
4073 .filter_map(|l| match l {
4074 LinkRef::Name(s) => Some(s),
4075 LinkRef::Id(_) => None,
4076 })
4077 .collect();
4078 assert_eq!(slugs, vec!["real-target".to_string()]);
4079 }
4080
4081 #[test]
4083 fn unified_walker_emits_both_link_types_in_one_pass() {
4084 let doc = Document {
4085 blocks: vec![Block::Paragraph {
4086 inlines: vec![
4087 Inline::Link {
4088 target: "id:abc".into(),
4089 description: None,
4090 },
4091 Inline::Link {
4092 target: "alpha".into(),
4093 description: None,
4094 },
4095 Inline::Link {
4096 target: "id:def".into(),
4097 description: None,
4098 },
4099 Inline::Link {
4100 target: "beta".into(),
4101 description: None,
4102 },
4103 ],
4104 }],
4105 };
4106 let refs = extract_links(&doc);
4107 assert_eq!(
4108 refs,
4109 vec![
4110 LinkRef::Id("abc".into()),
4111 LinkRef::Name("alpha".into()),
4112 LinkRef::Id("def".into()),
4113 LinkRef::Name("beta".into()),
4114 ]
4115 );
4116 }
4117
4118 #[test]
4119 fn list_orphans_counts_across_both_link_types() {
4120 let conn = setup();
4121 insert_node(&conn, "hub", &named_doc("hub", "x")).unwrap();
4123 insert_node(&conn, "src-name", &referencing_doc(&["hub"])).unwrap();
4124 insert_node(&conn, "id-hub", &empty_doc()).unwrap();
4126 insert_node(&conn, "src-id", &link_doc(&["id-hub"])).unwrap();
4127 insert_node(
4129 &conn,
4130 "lonely",
4131 &Document {
4132 blocks: vec![Block::Paragraph {
4133 inlines: vec![Inline::Plain("no one links to me".into())],
4134 }],
4135 },
4136 )
4137 .unwrap();
4138 let orphans = list_orphans(&conn).unwrap();
4139 let ids: Vec<&str> = orphans.iter().map(|r| r.id.as_str()).collect();
4140 assert!(!ids.contains(&"hub"));
4143 assert!(!ids.contains(&"id-hub"));
4144 assert!(ids.contains(&"src-name"));
4145 assert!(ids.contains(&"src-id"));
4146 assert!(ids.contains(&"lonely"));
4147 }
4148
4149 #[test]
4150 fn list_hubs_ranks_by_total_in_degree_across_link_types() {
4151 let conn = setup();
4152 insert_node(&conn, "hot", &named_doc("hot", "x")).unwrap();
4153 insert_node(&conn, "warm", &empty_doc()).unwrap();
4154 insert_node(&conn, "a", &referencing_doc(&["hot"])).unwrap();
4156 insert_node(&conn, "b", &referencing_doc(&["hot"])).unwrap();
4157 insert_node(&conn, "c", &link_doc(&["hot"])).unwrap();
4159 insert_node(&conn, "d", &link_doc(&["warm"])).unwrap();
4161 let hubs = list_hubs(&conn, 10).unwrap();
4162 assert_eq!(hubs[0].id, "hot");
4163 assert_eq!(hubs[0].in_degree, 3);
4164 assert_eq!(hubs[1].id, "warm");
4165 assert_eq!(hubs[1].in_degree, 1);
4166 }
4167
4168 #[test]
4169 fn list_hubs_zero_limit_returns_empty() {
4170 let conn = setup();
4171 insert_node(&conn, "hot", &named_doc("hot", "x")).unwrap();
4172 insert_node(&conn, "a", &referencing_doc(&["hot"])).unwrap();
4173 assert!(list_hubs(&conn, 0).unwrap().is_empty());
4174 }
4175
4176 #[test]
4177 fn list_broken_links_returns_only_unresolved_name_rows() {
4178 let conn = setup();
4179 insert_node(&conn, "named", &named_doc("alpha", "x")).unwrap();
4180 insert_node(&conn, "src1", &referencing_doc(&["alpha", "missing"])).unwrap();
4181 insert_node(&conn, "src2", &referencing_doc(&["also-missing"])).unwrap();
4182 let broken = list_broken_links(&conn).unwrap();
4183 let slugs: Vec<&str> = broken
4184 .iter()
4185 .map(|r| r.target_slug.as_deref().unwrap_or(""))
4186 .collect();
4187 assert_eq!(slugs, vec!["also-missing", "missing"]);
4188 for row in &broken {
4189 assert_eq!(row.link_type, LinkType::Name);
4190 assert!(row.target_id.is_none());
4191 }
4192 }
4193
4194 #[test]
4195 fn get_links_returns_full_outgoing_including_broken() {
4196 let conn = setup();
4197 insert_node(&conn, "hub", &named_doc("hub", "x")).unwrap();
4198 insert_node(&conn, "src", &referencing_doc(&["hub", "ghost"])).unwrap();
4199 let nb = get_links(&conn, "src").unwrap();
4200 assert_eq!(nb.outgoing.len(), 2);
4202 let resolved = nb
4203 .outgoing
4204 .iter()
4205 .find(|r| r.target_slug.as_deref() == Some("hub"))
4206 .unwrap();
4207 assert_eq!(resolved.target_id.as_deref(), Some("hub"));
4208 let broken = nb
4209 .outgoing
4210 .iter()
4211 .find(|r| r.target_slug.as_deref() == Some("ghost"))
4212 .unwrap();
4213 assert!(broken.target_id.is_none());
4214 }
4215
4216 #[test]
4217 fn relink_all_skips_corrupt_blob_in_unified_path() {
4218 let conn = setup();
4219 insert_node(&conn, "good", &named_doc("g", "x")).unwrap();
4220 conn.execute(
4221 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) \
4222 VALUES ('bad', 'x', 'not-a-sexp', '0', '0')",
4223 [],
4224 )
4225 .unwrap();
4226 let (nodes, _) = relink_all(&conn).unwrap();
4227 assert_eq!(nodes, 2);
4228 }
4229}