1use crate::error::KbError;
41use std::cmp::Ordering;
42use std::collections::{BTreeMap, HashSet};
43
44use rusqlite::{Connection, OptionalExtension, params};
45
46use crate::embedding;
47use crate::sexp;
48use tftio_org::ast::{Block, Document, Inline, NodeId, Tag, Title};
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 && !parent.as_os_str().is_empty()
166 {
167 let _ = std::fs::create_dir_all(parent);
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,
356 pub title: Title,
358 pub ast_blob: String,
360 pub created_at: String,
362 pub updated_at: String,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
375#[serde(rename_all = "lowercase")]
376pub enum LinkType {
377 Id,
379 Name,
381}
382
383impl LinkType {
384 #[must_use]
386 pub const fn as_str(self) -> &'static str {
387 match self {
388 Self::Id => "id",
389 Self::Name => "name",
390 }
391 }
392}
393
394impl std::fmt::Display for LinkType {
395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396 f.write_str(self.as_str())
397 }
398}
399
400impl rusqlite::types::FromSql for LinkType {
401 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
402 match value.as_str()? {
403 "id" => Ok(Self::Id),
404 "name" => Ok(Self::Name),
405 _ => Err(rusqlite::types::FromSqlError::InvalidType),
408 }
409 }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct LinkRow {
419 pub source_id: String,
421 pub link_type: LinkType,
423 pub target_id: Option<String>,
425 pub target_slug: Option<String>,
427}
428
429#[derive(Debug, Clone)]
431pub struct AuditRow {
432 pub id: i64,
434 pub node_id: String,
436 pub operation: String,
438 pub old_blob: Option<String>,
440 pub new_blob: Option<String>,
442 pub timestamp: String,
444}
445
446#[derive(Debug, Clone, Default, PartialEq, Eq)]
449pub struct Neighborhood {
450 pub outgoing: Vec<(NodeId, LinkType)>,
452 pub incoming: Vec<(NodeId, LinkType)>,
454}
455
456fn now_iso8601() -> String {
457 chrono::Utc::now()
458 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
459 .to_string()
460}
461
462fn write_embedding_row(
468 conn: &Connection,
469 node_id: &str,
470 embedding: Option<Vec<f32>>,
471 model: Option<&str>,
472) {
473 let (Some(vec), Some(model)) = (embedding, model) else {
474 return;
475 };
476 let blob = embedding::encode_embedding(&vec);
477 if let Err(e) = conn.execute(
478 "INSERT OR REPLACE INTO embeddings (node_id, model, embedding) VALUES (?1, ?2, ?3)",
479 params![node_id, model, blob],
480 ) {
481 tracing::error!(node_id, error = %e, "kb embed write failed");
482 }
483}
484
485pub fn insert_node(
495 conn: &Connection,
496 node_id: &str,
497 document: &Document,
498) -> Result<(), rusqlite::Error> {
499 insert_node_with(conn, node_id, document, None, None)
500}
501
502pub fn insert_node_with(
519 conn: &Connection,
520 node_id: &str,
521 document: &Document,
522 embedding: Option<Vec<f32>>,
523 model: Option<&str>,
524) -> Result<(), rusqlite::Error> {
525 let blob = sexp::encode_document(document);
526 let title = extract_title(document);
527 let tags = extract_tags(document);
528 let body_text = extract_body_text(document);
529 let now = now_iso8601();
530
531 let tx = conn.unchecked_transaction()?;
532
533 tx.execute(
534 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
535 params![node_id, title, blob, now, now],
536 )?;
537 for tag in &tags {
538 tx.execute(
539 "INSERT INTO node_tags (node_id, tag) VALUES (?1, ?2)",
540 params![node_id, tag.0.as_str()],
541 )?;
542 }
543 tx.execute(
544 "INSERT INTO nodes_fts (node_id, title, body) VALUES (?1, ?2, ?3)",
545 params![node_id, title, body_text],
546 )?;
547 tx.execute(
548 "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'insert', NULL, ?2, ?3)",
549 params![node_id, blob, now],
550 )?;
551
552 relink_one_inner(&tx, node_id, document)?;
553 if let Some(slug) = extract_name_slug(document) {
556 tx.execute(
557 "UPDATE links SET target_id = ?1 \
558 WHERE link_type = 'name' AND target_slug = ?2 AND target_id IS NULL",
559 params![node_id, slug],
560 )?;
561 }
562
563 tx.commit()?;
564
565 write_embedding_row(conn, node_id, embedding, model);
566
567 Ok(())
568}
569
570pub fn get_node(conn: &Connection, id: &str) -> Result<Option<Document>, KbError> {
577 let mut stmt = conn.prepare("SELECT ast_blob FROM nodes WHERE id = ?1")?;
578 let mut rows = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
579 match rows.next() {
580 Some(Ok(blob)) => {
581 let doc = decode_blob(id, &blob)?;
582 Ok(Some(doc))
583 }
584 Some(Err(e)) => Err(KbError::Database(e)),
585 None => Ok(None),
586 }
587}
588
589fn decode_blob(node_id: &str, blob: &str) -> Result<Document, KbError> {
591 sexp::decode_document(blob).map_err(|e| KbError::CorruptAstBlob {
592 node_id: node_id.to_string(),
593 reason: e.to_string(),
594 })
595}
596
597pub fn get_node_row(conn: &Connection, id: &str) -> Result<Option<NodeRow>, KbError> {
603 let mut stmt = conn
604 .prepare("SELECT id, title, ast_blob, created_at, updated_at FROM nodes WHERE id = ?1")?;
605 let mut rows = stmt.query_map(params![id], |row| {
606 Ok(NodeRow {
607 id: NodeId(row.get::<_, String>(0)?),
608 title: Title(row.get::<_, String>(1)?),
609 ast_blob: row.get(2)?,
610 created_at: row.get(3)?,
611 updated_at: row.get(4)?,
612 })
613 })?;
614 match rows.next() {
615 Some(Ok(row)) => Ok(Some(row)),
616 Some(Err(e)) => Err(KbError::Database(e)),
617 None => Ok(None),
618 }
619}
620
621pub fn update_node(
629 conn: &Connection,
630 id: &str,
631 document: &Document,
632) -> Result<bool, rusqlite::Error> {
633 update_node_with(conn, id, document, None, None)
634}
635
636pub fn update_node_with(
653 conn: &Connection,
654 id: &str,
655 document: &Document,
656 embedding: Option<Vec<f32>>,
657 model: Option<&str>,
658) -> Result<bool, rusqlite::Error> {
659 let tx = conn.unchecked_transaction()?;
660
661 let prior_blob: Option<String> = tx
662 .query_row(
663 "SELECT ast_blob FROM nodes WHERE id = ?1",
664 params![id],
665 |row| row.get(0),
666 )
667 .optional()?;
668 let Some(prior_blob) = prior_blob else {
669 return Ok(false);
670 };
671
672 let blob = sexp::encode_document(document);
673 let title = extract_title(document);
674 let tags = extract_tags(document);
675 let body_text = extract_body_text(document);
676 let now = now_iso8601();
677
678 tx.execute(
679 "UPDATE nodes SET title = ?1, ast_blob = ?2, updated_at = ?3 WHERE id = ?4",
680 params![title, blob, now, id],
681 )?;
682 tx.execute("DELETE FROM node_tags WHERE node_id = ?1", params![id])?;
683 for tag in &tags {
684 tx.execute(
685 "INSERT INTO node_tags (node_id, tag) VALUES (?1, ?2)",
686 params![id, tag.0.as_str()],
687 )?;
688 }
689 tx.execute("DELETE FROM nodes_fts WHERE node_id = ?1", params![id])?;
690 tx.execute(
691 "INSERT INTO nodes_fts (node_id, title, body) VALUES (?1, ?2, ?3)",
692 params![id, title, body_text],
693 )?;
694 tx.execute(
695 "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'update', ?2, ?3, ?4)",
696 params![id, prior_blob, blob, now],
697 )?;
698
699 let prior_slug = sexp::decode_document(&prior_blob)
704 .ok()
705 .as_ref()
706 .and_then(extract_name_slug);
707 let new_slug = extract_name_slug(document);
708 if prior_slug.as_deref() != new_slug.as_deref() {
709 invalidate_resolved_pointing_at(&tx, id)?;
710 }
711 relink_one_inner(&tx, id, document)?;
712 if let Some(slug) = new_slug {
715 tx.execute(
716 "UPDATE links SET target_id = ?1 \
717 WHERE link_type = 'name' AND target_slug = ?2 AND target_id IS NULL",
718 params![id, slug],
719 )?;
720 }
721
722 tx.commit()?;
723
724 write_embedding_row(conn, id, embedding, model);
725
726 Ok(true)
727}
728
729pub fn delete_node(conn: &Connection, id: &str) -> Result<bool, rusqlite::Error> {
737 let tx = conn.unchecked_transaction()?;
738
739 let prior_blob: Option<String> = tx
740 .query_row(
741 "SELECT ast_blob FROM nodes WHERE id = ?1",
742 params![id],
743 |row| row.get(0),
744 )
745 .optional()?;
746 let Some(prior_blob) = prior_blob else {
747 return Ok(false);
748 };
749
750 tx.execute(
751 "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'delete', ?2, NULL, ?3)",
752 params![id, prior_blob, now_iso8601()],
753 )?;
754
755 tx.execute("DELETE FROM nodes_fts WHERE node_id = ?1", params![id])?;
756 tx.execute(
762 "DELETE FROM links WHERE link_type = 'id' AND target_id = ?1",
763 params![id],
764 )?;
765 tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
766
767 tx.commit()?;
768 Ok(true)
769}
770
771pub fn list_by_tag(conn: &Connection, tag: &str) -> Result<Vec<NodeRow>, rusqlite::Error> {
780 let tag = normalize_tag(tag);
781 let mut stmt = conn.prepare(
782 "SELECT n.id, n.title, n.ast_blob, n.created_at, n.updated_at \
783 FROM nodes n JOIN node_tags nt ON n.id = nt.node_id \
784 WHERE nt.tag = ?1 ORDER BY n.updated_at DESC",
785 )?;
786 let rows = stmt.query_map(params![tag], |row| {
787 Ok(NodeRow {
788 id: NodeId(row.get::<_, String>(0)?),
789 title: Title(row.get::<_, String>(1)?),
790 ast_blob: row.get(2)?,
791 created_at: row.get(3)?,
792 updated_at: row.get(4)?,
793 })
794 })?;
795 rows.collect()
796}
797
798pub fn list_recent(conn: &Connection, limit: usize) -> Result<Vec<NodeRow>, rusqlite::Error> {
804 let mut stmt = conn.prepare(
805 "SELECT id, title, ast_blob, created_at, updated_at FROM nodes ORDER BY updated_at DESC LIMIT ?1",
806 )?;
807 let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(i64::MAX)], |row| {
808 Ok(NodeRow {
809 id: NodeId(row.get::<_, String>(0)?),
810 title: Title(row.get::<_, String>(1)?),
811 ast_blob: row.get(2)?,
812 created_at: row.get(3)?,
813 updated_at: row.get(4)?,
814 })
815 })?;
816 rows.collect()
817}
818
819pub fn list_all_nodes(
826 conn: &Connection,
827 limit: usize,
828 offset: usize,
829) -> Result<Vec<(NodeId, Title)>, rusqlite::Error> {
830 if limit == 0 {
831 return Ok(Vec::new());
832 }
833 let mut stmt =
834 conn.prepare("SELECT id, title FROM nodes ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2")?;
835 let rows = stmt.query_map(
836 params![
837 i64::try_from(limit).unwrap_or(i64::MAX),
838 i64::try_from(offset).unwrap_or(i64::MAX)
839 ],
840 |row| Ok((NodeId(row.get(0)?), Title(row.get(1)?))),
841 )?;
842 rows.collect()
843}
844
845#[derive(Debug, Clone)]
847pub struct NodeFullData {
848 pub title: String,
850 pub tags: Vec<Tag>,
852 pub document: Document,
854 pub created_at: String,
856 pub updated_at: String,
858}
859
860pub fn get_node_full(conn: &Connection, id: &str) -> Result<Option<NodeFullData>, KbError> {
867 let mut stmt =
868 conn.prepare("SELECT title, ast_blob, created_at, updated_at FROM nodes WHERE id = ?1")?;
869 let mut rows = stmt.query_map(params![id], |row| {
870 Ok((
871 row.get::<_, String>(0)?,
872 row.get::<_, String>(1)?,
873 row.get::<_, String>(2)?,
874 row.get::<_, String>(3)?,
875 ))
876 })?;
877 match rows.next() {
878 Some(Ok((title, blob, created_at, updated_at))) => {
879 let doc = decode_blob(id, &blob)?;
880 let tags = get_node_tags(conn, id)?;
881 Ok(Some(NodeFullData {
882 title,
883 tags,
884 document: doc,
885 created_at,
886 updated_at,
887 }))
888 }
889 Some(Err(e)) => Err(KbError::Database(e)),
890 None => Ok(None),
891 }
892}
893
894fn get_node_tags(conn: &Connection, node_id: &str) -> Result<Vec<Tag>, rusqlite::Error> {
896 let mut stmt = conn.prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
897 let rows = stmt.query_map(params![node_id], |row| row.get::<_, String>(0))?;
898 rows.map(|r| r.map(Tag)).collect()
899}
900
901pub fn fetch_titles(
908 conn: &Connection,
909 ids: &[String],
910) -> Result<Vec<(String, String)>, rusqlite::Error> {
911 if ids.is_empty() {
912 return Ok(Vec::new());
913 }
914 let placeholders: Vec<String> = ids
915 .iter()
916 .enumerate()
917 .map(|(i, _)| format!("?{}", i + 1))
918 .collect();
919 let sql = format!(
920 "SELECT id, title FROM nodes WHERE id IN ({})",
921 placeholders.join(",")
922 );
923 let mut stmt = conn.prepare(&sql)?;
924 let bound: Vec<&dyn rusqlite::types::ToSql> = ids
925 .iter()
926 .map(|id| id as &dyn rusqlite::types::ToSql)
927 .collect();
928 let rows = stmt.query_map(bound.as_slice(), |row| {
929 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
930 })?;
931 let mut map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
932 for row in rows {
933 let (id, title) = row?;
934 map.insert(id, title);
935 }
936 Ok(ids
937 .iter()
938 .map(|id| (id.clone(), map.get(id).cloned().unwrap_or_default()))
939 .collect())
940}
941
942pub fn search_fts(conn: &Connection, query: &str) -> Result<Vec<String>, rusqlite::Error> {
948 let q = query.trim();
949 if q.is_empty() {
950 return Ok(Vec::new());
951 }
952 let terms: Vec<String> = q.split_whitespace().map(|t| format!("\"{t}\"*")).collect();
953 let fts_query = terms.join(" AND ");
954
955 let mut stmt =
956 conn.prepare("SELECT node_id FROM nodes_fts WHERE nodes_fts MATCH ?1 ORDER BY rank")?;
957 let rows = stmt.query_map(params![fts_query], |row| row.get::<_, String>(0))?;
958 let mut results = Vec::new();
959 for row in rows {
960 let id = row?;
961 if !results.contains(&id) {
962 results.push(id);
963 }
964 }
965 Ok(results)
966}
967
968#[must_use]
974pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
975 if a.len() != b.len() {
976 return 0.0;
977 }
978 let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
979 let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
980 if na == 0.0 || nb == 0.0 {
981 return 0.0;
982 }
983 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
984 dot / (na * nb)
985}
986
987#[must_use]
994pub fn reciprocal_rank_fusion(k: usize, lists: &[Vec<NodeId>]) -> Vec<NodeId> {
995 let mut scores: BTreeMap<String, f64> = BTreeMap::new();
996 for list in lists {
997 for (rank0, NodeId(id)) in list.iter().enumerate() {
998 let rank = rank0 + 1;
999 #[allow(
1000 clippy::cast_precision_loss,
1001 reason = "(k + rank) is a small positive rank index; the f64 cast for RRF scoring loses no significant precision"
1002 )]
1003 let contribution = 1.0_f64 / (k + rank) as f64;
1004 *scores.entry(id.clone()).or_insert(0.0) += contribution;
1005 }
1006 }
1007 let mut entries: Vec<(String, f64)> = scores.into_iter().collect();
1008 entries.sort_by(|a, b| {
1011 b.1.partial_cmp(&a.1)
1012 .unwrap_or(Ordering::Equal)
1013 .then_with(|| a.0.cmp(&b.0))
1014 });
1015 entries.into_iter().map(|(s, _)| NodeId(s)).collect()
1016}
1017
1018pub fn rank_by_embedding(
1026 conn: &Connection,
1027 query_vec: &[f32],
1028 model: &str,
1029) -> Result<Vec<NodeId>, rusqlite::Error> {
1030 let mut stmt = conn.prepare("SELECT node_id, embedding FROM embeddings WHERE model = ?1")?;
1031 let rows = stmt.query_map(params![model], |row| {
1032 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1033 })?;
1034 let mut scored: Vec<(String, f32)> = Vec::new();
1035 for row in rows {
1036 let (nid, blob) = row?;
1037 if let Ok(ev) = embedding::decode_embedding(&blob) {
1038 let s = cosine_similarity(query_vec, &ev);
1039 scored.push((nid, s));
1040 }
1041 }
1042 scored.sort_by(|a, b| {
1043 b.1.partial_cmp(&a.1)
1044 .unwrap_or(Ordering::Equal)
1045 .then_with(|| a.0.cmp(&b.0))
1046 });
1047 Ok(scored.into_iter().map(|(id, _)| NodeId(id)).collect())
1048}
1049
1050pub fn search_hybrid(
1073 conn: &Connection,
1074 query: &str,
1075 query_embedding: Option<(Vec<f32>, &str)>,
1076) -> Result<Vec<NodeId>, rusqlite::Error> {
1077 let keyword_ranked: Vec<NodeId> = search_fts(conn, query)?.into_iter().map(NodeId).collect();
1078 let vector_ranked: Vec<NodeId> = match query_embedding {
1079 None => Vec::new(),
1080 Some((qv, model)) => {
1081 if query.trim().is_empty() && keyword_ranked.is_empty() {
1082 Vec::new()
1083 } else {
1084 rank_by_embedding(conn, &qv, model)?
1085 }
1086 }
1087 };
1088 Ok(reciprocal_rank_fusion(60, &[keyword_ranked, vector_ranked]))
1089}
1090
1091#[derive(Debug, thiserror::Error)]
1099pub enum VecExtensionError {
1100 #[error("enable_load_extension failed: {0}")]
1102 EnableLoad(rusqlite::Error),
1103
1104 #[error("load_extension failed: {0}")]
1106 LoadExtension(rusqlite::Error),
1107}
1108
1109pub fn try_load_vec_extension(conn: &Connection, path: &str) -> Result<(), VecExtensionError> {
1121 #[allow(
1127 unsafe_code,
1128 reason = "FFI: loads the sqlite-vec dynamic extension; soundness argued in the SAFETY comment above"
1129 )]
1130 let result: Result<(), VecExtensionError> = unsafe {
1131 let _guard =
1132 rusqlite::LoadExtensionGuard::new(conn).map_err(VecExtensionError::EnableLoad)?;
1133 conn.load_extension(path, None::<&str>)
1134 .map_err(VecExtensionError::LoadExtension)?;
1135 Ok(())
1136 };
1137 result
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq)]
1149pub enum LinkRef {
1150 Id(String),
1152 Name(String),
1154}
1155
1156#[must_use]
1171pub fn extract_links(doc: &Document) -> Vec<LinkRef> {
1172 let mut seen_id = HashSet::<String>::new();
1173 let mut seen_name = HashSet::<String>::new();
1174 let mut out = Vec::new();
1175 collect_links_blocks(&doc.blocks, &mut seen_id, &mut seen_name, &mut out);
1176 out
1177}
1178
1179fn is_bracket_name_ref(target: &str) -> bool {
1182 !target.is_empty() && !target.contains(':') && !target.contains('/')
1183}
1184
1185fn collect_links_blocks(
1186 blocks: &[Block],
1187 seen_id: &mut HashSet<String>,
1188 seen_name: &mut HashSet<String>,
1189 out: &mut Vec<LinkRef>,
1190) {
1191 for block in blocks {
1192 match block {
1193 Block::Heading { children, .. } | Block::QuoteBlock { children } => {
1194 collect_links_blocks(children, seen_id, seen_name, out);
1195 }
1196 Block::Paragraph { inlines } => {
1197 for inline in inlines {
1198 collect_links_inline(inline, seen_id, seen_name, out);
1199 }
1200 }
1201 Block::List { items, .. } => {
1202 for item in items {
1203 collect_links_blocks(&item.content, seen_id, seen_name, out);
1204 }
1205 }
1206 Block::Table { rows } => {
1207 for row in rows {
1208 for cell in row {
1209 for inline in &cell.inlines {
1210 collect_links_inline(inline, seen_id, seen_name, out);
1211 }
1212 }
1213 }
1214 }
1215 _ => {}
1218 }
1219 }
1220}
1221
1222fn collect_links_inline(
1223 inline: &Inline,
1224 seen_id: &mut HashSet<String>,
1225 seen_name: &mut HashSet<String>,
1226 out: &mut Vec<LinkRef>,
1227) {
1228 match inline {
1229 Inline::Link {
1230 target,
1231 description,
1232 } => {
1233 if let Some(rest) = target.strip_prefix("id:") {
1234 let id = rest.to_string();
1236 if !id.is_empty() && seen_id.insert(id.clone()) {
1237 out.push(LinkRef::Id(id));
1238 }
1239 } else if description.is_none() && is_bracket_name_ref(target) {
1240 let slug = target.clone();
1241 if seen_name.insert(slug.clone()) {
1242 out.push(LinkRef::Name(slug));
1243 }
1244 }
1245 }
1246 Inline::Bold(is) | Inline::Italic(is) | Inline::Strikethrough(is) => {
1247 for i in is {
1248 collect_links_inline(i, seen_id, seen_name, out);
1249 }
1250 }
1251 _ => {}
1254 }
1255}
1256
1257#[must_use]
1260pub fn extract_name_slug(doc: &Document) -> Option<String> {
1261 for block in &doc.blocks {
1262 if let Block::Keyword { name, value } = block
1263 && name.eq_ignore_ascii_case("name")
1264 {
1265 let trimmed = value.trim();
1266 if !trimmed.is_empty() {
1267 return Some(trimmed.to_string());
1268 }
1269 }
1270 }
1271 None
1272}
1273
1274pub fn relink_one(
1287 conn: &Connection,
1288 source_id: &str,
1289 document: &Document,
1290) -> Result<(), rusqlite::Error> {
1291 let tx = conn.unchecked_transaction()?;
1292 relink_one_inner(&tx, source_id, document)?;
1293 tx.commit()?;
1294 Ok(())
1295}
1296
1297fn relink_one_inner(
1304 conn: &Connection,
1305 source_id: &str,
1306 document: &Document,
1307) -> Result<(), rusqlite::Error> {
1308 let refs = extract_links(document);
1309 conn.execute("DELETE FROM links WHERE source_id = ?1", params![source_id])?;
1310 if refs.is_empty() {
1311 return Ok(());
1312 }
1313 let mut ins_id = conn.prepare(
1314 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1315 VALUES (?1, 'id', ?2, NULL)",
1316 )?;
1317 let mut ins_name = conn.prepare(
1318 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1319 VALUES (?1, 'name', ?2, ?3)",
1320 )?;
1321 for r in &refs {
1322 match r {
1323 LinkRef::Id(tgt) => {
1324 if node_exists(conn, tgt)? {
1325 ins_id.execute(params![source_id, tgt])?;
1326 }
1327 }
1328 LinkRef::Name(slug) => {
1329 let dst_id = resolve_slug_to_id(conn, slug)?;
1330 ins_name.execute(params![source_id, dst_id, slug])?;
1331 }
1332 }
1333 }
1334 Ok(())
1335}
1336
1337fn node_exists(conn: &Connection, id: &str) -> Result<bool, rusqlite::Error> {
1338 let n: Option<i64> = conn
1339 .query_row(
1340 "SELECT 1 FROM nodes WHERE id = ?1 LIMIT 1",
1341 params![id],
1342 |r| r.get(0),
1343 )
1344 .optional()?;
1345 Ok(n.is_some())
1346}
1347
1348pub fn relink_all(conn: &Connection) -> Result<(usize, usize), rusqlite::Error> {
1361 let rows: Vec<(String, String)> = {
1362 let mut stmt = conn.prepare("SELECT id, ast_blob FROM nodes")?;
1363 let mapped = stmt.query_map([], |row| {
1364 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1365 })?;
1366 mapped.collect::<Result<_, _>>()?
1367 };
1368
1369 let tx = conn.unchecked_transaction()?;
1370 tx.execute("DELETE FROM links", [])?;
1371
1372 let mut nodes_processed: usize = 0;
1373 let mut links_written: usize = 0;
1374 {
1375 let mut ins_id = tx.prepare(
1376 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1377 VALUES (?1, 'id', ?2, NULL)",
1378 )?;
1379 let mut ins_name = tx.prepare(
1380 "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1381 VALUES (?1, 'name', ?2, ?3)",
1382 )?;
1383 for (nid, blob) in &rows {
1384 nodes_processed += 1;
1385 let Ok(doc) = sexp::decode_document(blob) else {
1386 continue;
1387 };
1388 for r in extract_links(&doc) {
1389 match r {
1390 LinkRef::Id(tgt) => {
1391 if node_exists(&tx, &tgt)? {
1392 ins_id.execute(params![nid, &tgt])?;
1393 links_written += 1;
1394 }
1395 }
1396 LinkRef::Name(slug) => {
1397 let dst_id = resolve_slug_to_id(&tx, &slug)?;
1398 ins_name.execute(params![nid, dst_id, &slug])?;
1399 links_written += 1;
1400 }
1401 }
1402 }
1403 }
1404 }
1405
1406 tx.commit()?;
1407 Ok((nodes_processed, links_written))
1408}
1409
1410fn resolve_slug_to_id(conn: &Connection, slug: &str) -> Result<Option<String>, rusqlite::Error> {
1414 let mut stmt = conn.prepare("SELECT id, ast_blob FROM nodes ORDER BY id")?;
1415 let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
1416 for row in rows {
1417 let (id, blob) = row?;
1418 let Ok(doc) = sexp::decode_document(&blob) else {
1419 continue;
1420 };
1421 if let Some(node_slug) = extract_name_slug(&doc)
1422 && node_slug == slug
1423 {
1424 return Ok(Some(id));
1425 }
1426 }
1427 Ok(None)
1428}
1429
1430fn invalidate_resolved_pointing_at(
1435 conn: &Connection,
1436 node_id: &str,
1437) -> Result<(), rusqlite::Error> {
1438 conn.execute(
1439 "UPDATE links SET target_id = NULL \
1440 WHERE link_type = 'name' AND target_id = ?1",
1441 params![node_id],
1442 )?;
1443 Ok(())
1444}
1445
1446pub fn get_neighborhood(conn: &Connection, node_id: &str) -> Result<Neighborhood, rusqlite::Error> {
1458 let outgoing: Vec<(NodeId, LinkType)> = {
1459 let mut stmt = conn.prepare(
1460 "SELECT target_id, link_type FROM links \
1461 WHERE source_id = ?1 AND target_id IS NOT NULL",
1462 )?;
1463 let rows = stmt.query_map(params![node_id], |row| {
1464 Ok((NodeId(row.get(0)?), row.get::<_, LinkType>(1)?))
1465 })?;
1466 rows.collect::<Result<_, _>>()?
1467 };
1468 let incoming: Vec<(NodeId, LinkType)> = {
1469 let mut stmt =
1470 conn.prepare("SELECT source_id, link_type FROM links WHERE target_id = ?1")?;
1471 let rows = stmt.query_map(params![node_id], |row| {
1472 Ok((NodeId(row.get(0)?), row.get::<_, LinkType>(1)?))
1473 })?;
1474 rows.collect::<Result<_, _>>()?
1475 };
1476 Ok(Neighborhood { outgoing, incoming })
1477}
1478
1479#[derive(Debug, Clone, Default, PartialEq, Eq)]
1485pub struct LinkNeighborhood {
1486 pub outgoing: Vec<LinkRow>,
1489 pub incoming: Vec<LinkRow>,
1492}
1493
1494pub fn get_links(conn: &Connection, node_id: &str) -> Result<LinkNeighborhood, rusqlite::Error> {
1502 let outgoing: Vec<LinkRow> = {
1503 let mut stmt = conn.prepare(
1504 "SELECT source_id, link_type, target_id, target_slug FROM links \
1505 WHERE source_id = ?1 \
1506 ORDER BY link_type, target_slug, target_id",
1507 )?;
1508 let rows = stmt.query_map(params![node_id], |r| {
1509 Ok(LinkRow {
1510 source_id: r.get(0)?,
1511 link_type: r.get(1)?,
1512 target_id: r.get(2)?,
1513 target_slug: r.get(3)?,
1514 })
1515 })?;
1516 rows.collect::<Result<_, _>>()?
1517 };
1518 let incoming: Vec<LinkRow> = {
1519 let mut stmt = conn.prepare(
1520 "SELECT source_id, link_type, target_id, target_slug FROM links \
1521 WHERE target_id = ?1 \
1522 ORDER BY link_type, source_id",
1523 )?;
1524 let rows = stmt.query_map(params![node_id], |r| {
1525 Ok(LinkRow {
1526 source_id: r.get(0)?,
1527 link_type: r.get(1)?,
1528 target_id: r.get(2)?,
1529 target_slug: r.get(3)?,
1530 })
1531 })?;
1532 rows.collect::<Result<_, _>>()?
1533 };
1534 Ok(LinkNeighborhood { outgoing, incoming })
1535}
1536
1537pub fn list_orphans(conn: &Connection) -> Result<Vec<NodeRow>, rusqlite::Error> {
1547 let mut stmt = conn.prepare(
1548 "SELECT n.id, n.title, n.ast_blob, n.created_at, n.updated_at \
1549 FROM nodes n \
1550 WHERE NOT EXISTS ( \
1551 SELECT 1 FROM links l \
1552 WHERE l.target_id = n.id \
1553 ) \
1554 ORDER BY n.updated_at DESC",
1555 )?;
1556 let rows = stmt.query_map([], |row| {
1557 Ok(NodeRow {
1558 id: NodeId(row.get::<_, String>(0)?),
1559 title: Title(row.get::<_, String>(1)?),
1560 ast_blob: row.get(2)?,
1561 created_at: row.get(3)?,
1562 updated_at: row.get(4)?,
1563 })
1564 })?;
1565 rows.collect()
1566}
1567
1568#[derive(Debug, Clone, PartialEq, Eq)]
1571pub struct HubEntry {
1572 pub id: String,
1574 pub title: String,
1576 pub in_degree: i64,
1578}
1579
1580pub fn list_hubs(conn: &Connection, limit: usize) -> Result<Vec<HubEntry>, rusqlite::Error> {
1588 if limit == 0 {
1589 return Ok(Vec::new());
1590 }
1591 let mut stmt = conn.prepare(
1592 "SELECT n.id, n.title, COUNT(l.source_id) AS in_degree \
1593 FROM nodes n \
1594 JOIN links l ON l.target_id = n.id \
1595 GROUP BY n.id, n.title \
1596 ORDER BY in_degree DESC, n.id ASC \
1597 LIMIT ?1",
1598 )?;
1599 let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(i64::MAX)], |r| {
1600 Ok(HubEntry {
1601 id: r.get(0)?,
1602 title: r.get(1)?,
1603 in_degree: r.get(2)?,
1604 })
1605 })?;
1606 rows.collect()
1607}
1608
1609pub fn list_broken_links(conn: &Connection) -> Result<Vec<LinkRow>, rusqlite::Error> {
1621 let mut stmt = conn.prepare(
1622 "SELECT source_id, link_type, target_id, target_slug FROM links \
1623 WHERE link_type = 'name' AND target_id IS NULL \
1624 ORDER BY target_slug ASC, source_id ASC",
1625 )?;
1626 let rows = stmt.query_map([], |r| {
1627 Ok(LinkRow {
1628 source_id: r.get(0)?,
1629 link_type: r.get(1)?,
1630 target_id: r.get(2)?,
1631 target_slug: r.get(3)?,
1632 })
1633 })?;
1634 rows.collect()
1635}
1636
1637#[must_use]
1643pub fn extract_title(doc: &Document) -> String {
1644 title_keyword(&doc.blocks)
1645 .or_else(|| find_first_title(&doc.blocks))
1646 .map_or_else(|| "(untitled)".into(), |t| truncate80(t.trim()))
1647}
1648
1649fn title_keyword(blocks: &[Block]) -> Option<String> {
1651 blocks.iter().find_map(|block| match block {
1652 Block::Keyword { name, value } if name.eq_ignore_ascii_case("title") => {
1653 let trimmed = value.trim();
1654 (!trimmed.is_empty()).then(|| trimmed.to_string())
1655 }
1656 _ => None,
1657 })
1658}
1659
1660fn find_first_title(blocks: &[Block]) -> Option<String> {
1661 for block in blocks {
1662 match block {
1663 Block::Heading {
1664 title, children, ..
1665 } => {
1666 if !title.0.trim().is_empty() {
1667 return Some(title.0.clone());
1668 }
1669 if let Some(t) = find_first_title(children) {
1670 return Some(t);
1671 }
1672 }
1673 Block::Paragraph { inlines } => {
1674 let text = inline_text(inlines);
1675 if !text.trim().is_empty() {
1676 return Some(text);
1677 }
1678 }
1679 Block::QuoteBlock { children } => {
1680 if let Some(t) = find_first_title(children) {
1681 return Some(t);
1682 }
1683 }
1684 _ => {}
1685 }
1686 }
1687 None
1688}
1689
1690fn truncate80(s: &str) -> String {
1691 if s.chars().count() <= 80 {
1692 s.to_string()
1693 } else {
1694 s.chars().take(80).collect()
1695 }
1696}
1697
1698#[must_use]
1702pub fn extract_tags(doc: &Document) -> Vec<Tag> {
1703 let mut seen = HashSet::new();
1704 let mut result = Vec::new();
1705 collect_tags(&doc.blocks, &mut seen, &mut result);
1706 result
1707}
1708
1709#[must_use]
1718pub fn normalize_tag(raw: &str) -> String {
1719 let mut out = String::with_capacity(raw.len());
1720 let mut pending_sep = false;
1721 for ch in raw.chars() {
1722 if ch.is_alphanumeric() {
1723 if pending_sep && !out.is_empty() {
1724 out.push('-');
1725 }
1726 pending_sep = false;
1727 out.extend(ch.to_lowercase());
1728 } else {
1729 pending_sep = true;
1730 }
1731 }
1732 out
1733}
1734
1735fn parse_filetags(value: &str) -> impl Iterator<Item = String> + '_ {
1741 value
1742 .split(':')
1743 .map(str::trim)
1744 .filter(|s| !s.is_empty())
1745 .map(str::to_string)
1746}
1747
1748fn push_tag(raw: &str, seen: &mut HashSet<String>, out: &mut Vec<Tag>) {
1750 let tag = normalize_tag(raw);
1751 if !tag.is_empty() && seen.insert(tag.clone()) {
1752 out.push(Tag(tag));
1753 }
1754}
1755
1756fn collect_tags(blocks: &[Block], seen: &mut HashSet<String>, out: &mut Vec<Tag>) {
1757 for block in blocks {
1758 match block {
1759 Block::Heading { tags, children, .. } => {
1760 for tag in tags {
1761 push_tag(&tag.0, seen, out);
1762 }
1763 collect_tags(children, seen, out);
1764 }
1765 Block::QuoteBlock { children } => {
1766 collect_tags(children, seen, out);
1767 }
1768 Block::Keyword { name, value } if name.eq_ignore_ascii_case("filetags") => {
1769 for tag in parse_filetags(value) {
1770 push_tag(&tag, seen, out);
1771 }
1772 }
1773 _ => {}
1774 }
1775 }
1776}
1777
1778#[must_use]
1780pub fn extract_body_text(doc: &Document) -> String {
1781 let mut texts = Vec::new();
1782 collect_texts(&doc.blocks, &mut texts);
1783 texts
1784 .into_iter()
1785 .filter(|s| !s.is_empty())
1786 .collect::<Vec<_>>()
1787 .join(" ")
1788}
1789
1790fn collect_texts(blocks: &[Block], out: &mut Vec<String>) {
1791 for block in blocks {
1792 match block {
1793 Block::Heading { children, .. } | Block::QuoteBlock { children } => {
1794 collect_texts(children, out);
1795 }
1796 Block::Paragraph { inlines } => {
1797 out.push(inline_text(inlines));
1798 }
1799 Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => {
1800 out.push(content.clone());
1801 }
1802 Block::List { items, .. } => {
1803 for item in items {
1804 collect_texts(&item.content, out);
1805 }
1806 }
1807 Block::Table { rows } => {
1808 for row in rows {
1809 for cell in row {
1810 out.push(inline_text(&cell.inlines));
1811 }
1812 }
1813 }
1814 Block::PropertyDrawer { entries } => {
1815 for (_, v) in entries {
1816 out.push(v.clone());
1817 }
1818 }
1819 Block::LogbookDrawer { entries } => {
1820 for entry in entries {
1821 out.push(entry.note.clone());
1822 }
1823 }
1824 Block::Comment { text } => {
1825 out.push(text.clone());
1826 }
1827 Block::Keyword { value, .. } => {
1828 out.push(value.clone());
1829 }
1830 Block::Planning { .. } | Block::BlankLine | Block::HorizontalRule => {}
1831 }
1832 }
1833}
1834
1835fn inline_text(inlines: &[Inline]) -> String {
1836 let mut parts = Vec::new();
1837 for inline in inlines {
1838 match inline {
1839 Inline::Plain(t) | Inline::InlineCode(t) | Inline::Verbatim(t) => parts.push(t.clone()),
1840 Inline::Bold(is) | Inline::Italic(is) | Inline::Strikethrough(is) => {
1841 parts.push(inline_text(is));
1842 }
1843 Inline::Link {
1844 description: Some(d),
1845 ..
1846 } => parts.push(d.clone()),
1847 Inline::Link { target, .. } => parts.push(target.clone()),
1848 Inline::LineBreak => parts.push(" ".to_string()),
1849 }
1850 }
1851 parts.join("")
1852}
1853
1854#[cfg(test)]
1855mod tests {
1856 use super::*;
1857 use tftio_org::ast::{Checkbox, ListItem, ListType, TableCell};
1858
1859 fn setup() -> Connection {
1860 let conn = Connection::open_in_memory().unwrap();
1861 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
1862 init_db(&conn).unwrap();
1863 conn
1864 }
1865
1866 fn sample_doc() -> Document {
1867 Document {
1868 blocks: vec![Block::Heading {
1869 level: 1,
1870 title: Title("Test".into()),
1871 tags: vec![Tag("rust".into())],
1872 children: vec![Block::Paragraph {
1873 inlines: vec![Inline::Plain("hello world".into())],
1874 }],
1875 }],
1876 }
1877 }
1878
1879 fn link_doc(targets: &[&str]) -> Document {
1880 let inlines: Vec<Inline> = targets
1881 .iter()
1882 .map(|t| Inline::Link {
1883 target: format!("id:{t}"),
1884 description: None,
1885 })
1886 .collect();
1887 Document {
1888 blocks: vec![Block::Paragraph { inlines }],
1889 }
1890 }
1891
1892 #[test]
1895 fn insert_and_get_node() {
1896 let conn = setup();
1897 let id = "test-1";
1898 insert_node(&conn, id, &sample_doc()).unwrap();
1899 let doc = get_node(&conn, id).unwrap().unwrap();
1900 assert_eq!(doc, sample_doc());
1901 }
1902
1903 #[test]
1904 fn get_nonexistent_node() {
1905 let conn = setup();
1906 let doc = get_node(&conn, "no-such-id").unwrap();
1907 assert!(doc.is_none());
1908 }
1909
1910 #[test]
1911 fn update_node_works() {
1912 let conn = setup();
1913 let id = "test-2";
1914 insert_node(&conn, id, &sample_doc()).unwrap();
1915 let mut updated_doc = sample_doc();
1916 updated_doc.blocks.push(Block::Paragraph {
1917 inlines: vec![Inline::Plain("extra".into())],
1918 });
1919 let ok = update_node(&conn, id, &updated_doc).unwrap();
1920 assert!(ok);
1921 let doc = get_node(&conn, id).unwrap().unwrap();
1922 assert_eq!(doc, updated_doc);
1923 }
1924
1925 #[test]
1926 fn update_nonexistent_returns_false() {
1927 let conn = setup();
1928 let ok = update_node(&conn, "no-such", &sample_doc()).unwrap();
1929 assert!(!ok);
1930 }
1931
1932 #[test]
1933 fn delete_node_works() {
1934 let conn = setup();
1935 let id = "test-3";
1936 insert_node(&conn, id, &sample_doc()).unwrap();
1937 let ok = delete_node(&conn, id).unwrap();
1938 assert!(ok);
1939 assert!(get_node(&conn, id).unwrap().is_none());
1940 }
1941
1942 #[test]
1943 fn delete_nonexistent_returns_false() {
1944 let conn = setup();
1945 let ok = delete_node(&conn, "no-such").unwrap();
1946 assert!(!ok);
1947 }
1948
1949 #[test]
1950 fn list_by_tag_finds_match() {
1951 let conn = setup();
1952 insert_node(&conn, "a", &sample_doc()).unwrap();
1953
1954 let doc_b = Document {
1955 blocks: vec![Block::Heading {
1956 level: 1,
1957 title: Title("Other".into()),
1958 tags: vec![Tag("python".into())],
1959 children: vec![],
1960 }],
1961 };
1962 insert_node(&conn, "b", &doc_b).unwrap();
1963
1964 let results = list_by_tag(&conn, "rust").unwrap();
1965 assert_eq!(results.len(), 1);
1966 assert_eq!(results[0].id.as_str(), "a");
1967 }
1968
1969 #[test]
1970 fn list_recent_newest_first() {
1971 let conn = setup();
1972 insert_node(&conn, "first", &sample_doc()).unwrap();
1973 std::thread::sleep(std::time::Duration::from_millis(10));
1974 insert_node(&conn, "second", &sample_doc()).unwrap();
1975 let recent = list_recent(&conn, 10).unwrap();
1976 assert_eq!(recent.first().unwrap().id.as_str(), "second");
1977 }
1978
1979 #[test]
1980 fn title_from_first_heading() {
1981 let doc = Document {
1982 blocks: vec![Block::Heading {
1983 level: 1,
1984 title: Title("My Note".into()),
1985 tags: vec![],
1986 children: vec![],
1987 }],
1988 };
1989 assert_eq!(extract_title(&doc), "My Note");
1990 }
1991
1992 #[test]
1993 fn title_from_paragraph_fallback() {
1994 let doc = Document {
1995 blocks: vec![Block::Paragraph {
1996 inlines: vec![Inline::Plain("First paragraph text".into())],
1997 }],
1998 };
1999 assert_eq!(extract_title(&doc), "First paragraph text");
2000 }
2001
2002 #[test]
2003 fn title_untitled_when_empty() {
2004 let doc = Document { blocks: vec![] };
2005 assert_eq!(extract_title(&doc), "(untitled)");
2006 }
2007
2008 #[test]
2009 fn title_truncated_at_80() {
2010 let long = "a".repeat(100);
2011 let doc = Document {
2012 blocks: vec![Block::Paragraph {
2013 inlines: vec![Inline::Plain(long)],
2014 }],
2015 };
2016 let title = extract_title(&doc);
2017 assert_eq!(title.chars().count(), 80);
2018 }
2019
2020 #[test]
2021 fn extract_tags_collects_all() {
2022 let doc = Document {
2023 blocks: vec![
2024 Block::Heading {
2025 level: 1,
2026 title: Title("A".into()),
2027 tags: vec![Tag("rust".into()), Tag("kb".into())],
2028 children: vec![Block::Heading {
2029 level: 2,
2030 title: Title("B".into()),
2031 tags: vec![Tag("testing".into())],
2032 children: vec![],
2033 }],
2034 },
2035 Block::Heading {
2036 level: 1,
2037 title: Title("C".into()),
2038 tags: vec![Tag("rust".into())], children: vec![],
2040 },
2041 ],
2042 };
2043 let tags = extract_tags(&doc);
2044 assert_eq!(tags.len(), 3);
2045 assert_eq!(tags[0].0, "rust");
2046 assert_eq!(tags[1].0, "kb");
2047 assert_eq!(tags[2].0, "testing");
2048 }
2049
2050 #[test]
2051 fn extract_tags_includes_filetags_keyword() {
2052 let doc = Document {
2054 blocks: vec![
2055 Block::Keyword {
2056 name: "filetags".into(),
2057 value: " :design:claude-memory:project:adr:".into(),
2058 },
2059 Block::Heading {
2060 level: 1,
2061 title: Title("Decision".into()),
2062 tags: vec![Tag("design".into())], children: vec![],
2064 },
2065 ],
2066 };
2067 let extracted = extract_tags(&doc);
2068 let tags: Vec<&str> = extracted.iter().map(|t| t.0.as_str()).collect();
2069 assert_eq!(tags, ["design", "claude-memory", "project", "adr"]);
2070 }
2071
2072 #[test]
2073 fn normalize_tag_lowercase_kebab() {
2074 assert_eq!(normalize_tag("Rust"), "rust");
2075 assert_eq!(normalize_tag("Claude Memory"), "claude-memory");
2076 assert_eq!(normalize_tag("claude_memory"), "claude-memory");
2077 assert_eq!(normalize_tag("silent-critic"), "silent-critic");
2078 assert_eq!(normalize_tag("silentCritic"), "silentcritic");
2079 assert_eq!(normalize_tag(" spaced tag "), "spaced-tag");
2080 assert_eq!(normalize_tag("+++"), "");
2081 }
2082
2083 #[test]
2084 fn extract_tags_normalizes_heading_tags() {
2085 let doc = Document {
2086 blocks: vec![Block::Heading {
2087 level: 1,
2088 title: Title("H".into()),
2089 tags: vec![Tag("Rust".into()), Tag("rust".into())],
2090 children: vec![],
2091 }],
2092 };
2093 let tags = extract_tags(&doc);
2094 assert_eq!(tags.len(), 1);
2096 assert_eq!(tags[0].0, "rust");
2097 }
2098
2099 #[test]
2100 fn extract_title_prefers_title_keyword() {
2101 let doc = Document {
2102 blocks: vec![
2103 Block::Keyword {
2104 name: "title".into(),
2105 value: " The Real Title".into(),
2106 },
2107 Block::Heading {
2108 level: 1,
2109 title: Title("First Heading".into()),
2110 tags: vec![],
2111 children: vec![],
2112 },
2113 ],
2114 };
2115 assert_eq!(extract_title(&doc), "The Real Title");
2116 }
2117
2118 #[test]
2119 fn extract_title_falls_back_to_heading_without_keyword() {
2120 let doc = Document {
2121 blocks: vec![Block::Heading {
2122 level: 1,
2123 title: Title("First Heading".into()),
2124 tags: vec![],
2125 children: vec![],
2126 }],
2127 };
2128 assert_eq!(extract_title(&doc), "First Heading");
2129 }
2130
2131 #[test]
2132 fn sexp_blob_roundtrip() {
2133 let conn = setup();
2134 let id = "sexp-test";
2135 insert_node(&conn, id, &sample_doc()).unwrap();
2136 let row = get_node_row(&conn, id).unwrap().unwrap();
2137 assert!(row.ast_blob.starts_with("(kb-doc 1"));
2138 let decoded = sexp::decode_document(&row.ast_blob).unwrap();
2139 assert_eq!(decoded, sample_doc());
2140 }
2141
2142 #[test]
2145 fn schema_version_constant_is_two() {
2146 assert_eq!(CURRENT_SCHEMA_VERSION, 2);
2147 }
2148
2149 #[test]
2150 fn migration_reencodes_legacy_ordered_list_blob() {
2151 let conn = setup();
2152 let id = "11111111-1111-1111-1111-111111111111";
2153 insert_node(&conn, id, &sample_doc()).unwrap();
2154 let legacy = "(kb-doc 1 (list ordered (item no-checkbox (paragraph (plain \"x\")))))";
2157 conn.execute(
2158 "UPDATE nodes SET ast_blob = ?1 WHERE id = ?2",
2159 params![legacy, id],
2160 )
2161 .unwrap();
2162 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2163
2164 init_db(&conn).unwrap();
2165
2166 let row = get_node_row(&conn, id).unwrap().unwrap();
2167 assert!(row.ast_blob.contains("(list (ordered 1)"));
2168 let full = get_node_full(&conn, id).unwrap().unwrap();
2169 assert_eq!(
2170 full.document.blocks,
2171 vec![Block::List {
2172 list_type: ListType::Ordered(1),
2173 items: vec![ListItem {
2174 content: vec![Block::Paragraph {
2175 inlines: vec![Inline::Plain("x".into())],
2176 }],
2177 checkbox: Checkbox::NoCheckbox,
2178 }],
2179 }]
2180 );
2181 }
2182
2183 #[test]
2184 fn migration_skipped_at_current_version() {
2185 let conn = setup();
2186 let id = "22222222-2222-2222-2222-222222222222";
2187 insert_node(&conn, id, &sample_doc()).unwrap();
2188 let legacy = "(kb-doc 1 (list ordered (item no-checkbox (paragraph (plain \"x\")))))";
2191 conn.execute(
2192 "UPDATE nodes SET ast_blob = ?1 WHERE id = ?2",
2193 params![legacy, id],
2194 )
2195 .unwrap();
2196
2197 init_db(&conn).unwrap();
2198
2199 let row = get_node_row(&conn, id).unwrap().unwrap();
2200 assert_eq!(row.ast_blob, legacy);
2201 }
2202
2203 #[test]
2204 fn get_node_reports_corrupt_blob() {
2205 let conn = setup();
2206 let id = "44444444-4444-4444-4444-444444444444";
2207 insert_node(&conn, id, &sample_doc()).unwrap();
2208 conn.execute(
2209 "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2210 params![id],
2211 )
2212 .unwrap();
2213 match get_node(&conn, id).unwrap_err() {
2214 KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2215 other => panic!("expected CorruptAstBlob, got {other}"),
2216 }
2217 }
2218
2219 #[test]
2220 fn get_node_full_reports_corrupt_blob() {
2221 let conn = setup();
2222 let id = "55555555-5555-5555-5555-555555555555";
2223 insert_node(&conn, id, &sample_doc()).unwrap();
2224 conn.execute(
2225 "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2226 params![id],
2227 )
2228 .unwrap();
2229 match get_node_full(&conn, id).unwrap_err() {
2230 KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2231 other => panic!("expected CorruptAstBlob, got {other}"),
2232 }
2233 }
2234
2235 #[test]
2236 fn migration_reports_corrupt_blob() {
2237 let conn = setup();
2238 let id = "33333333-3333-3333-3333-333333333333";
2239 insert_node(&conn, id, &sample_doc()).unwrap();
2240 conn.execute(
2241 "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2242 params![id],
2243 )
2244 .unwrap();
2245 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2246
2247 let err = init_db(&conn).unwrap_err();
2248 match err {
2249 KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2250 other => panic!("expected CorruptAstBlob, got {other}"),
2251 }
2252 }
2253
2254 #[test]
2255 fn schema_version_pragma_is_stamped() {
2256 let conn = setup();
2257 let v: i64 = conn
2258 .query_row("PRAGMA user_version", [], |r| r.get(0))
2259 .unwrap();
2260 assert_eq!(v, i64::from(CURRENT_SCHEMA_VERSION));
2261 }
2262
2263 #[test]
2264 fn schema_links_table_columns_and_pk() {
2265 let conn = setup();
2266 let cols: Vec<(String, String, i64, i64)> = conn
2267 .prepare("PRAGMA table_info(links)")
2268 .unwrap()
2269 .query_map([], |r| {
2270 Ok((
2271 r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(5)?, ))
2276 })
2277 .unwrap()
2278 .collect::<Result<_, _>>()
2279 .unwrap();
2280 assert_eq!(
2285 cols,
2286 vec![
2287 ("source_id".into(), "TEXT".into(), 1, 1),
2288 ("link_type".into(), "TEXT".into(), 1, 2),
2289 ("target_id".into(), "TEXT".into(), 0, 3),
2290 ("target_slug".into(), "TEXT".into(), 0, 4),
2291 ]
2292 );
2293 }
2294
2295 #[test]
2296 fn schema_links_table_foreign_keys() {
2297 let conn = setup();
2298 let fks: Vec<(String, String, String, String)> = conn
2299 .prepare("PRAGMA foreign_key_list(links)")
2300 .unwrap()
2301 .query_map([], |r| {
2302 Ok((
2303 r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?, r.get::<_, String>(6)?, ))
2308 })
2309 .unwrap()
2310 .collect::<Result<_, _>>()
2311 .unwrap();
2312 assert_eq!(fks.len(), 2);
2313 let mut by_from: std::collections::HashMap<String, (String, String, String)> =
2314 std::collections::HashMap::new();
2315 for (table, from, to, on_delete) in fks {
2316 by_from.insert(from, (table, to, on_delete));
2317 }
2318 let src = by_from.get("source_id").expect("source_id FK present");
2319 assert_eq!(src.0, "nodes");
2320 assert_eq!(src.1, "id");
2321 assert_eq!(src.2, "CASCADE");
2322 let tgt = by_from.get("target_id").expect("target_id FK present");
2323 assert_eq!(tgt.0, "nodes");
2324 assert_eq!(tgt.1, "id");
2325 assert_eq!(tgt.2, "SET NULL");
2329 }
2330
2331 #[test]
2332 fn links_table_check_constraint_rejects_invalid_shapes() {
2333 let conn = setup();
2334 insert_node(&conn, "src", &empty_doc()).unwrap();
2336 let e = conn.execute(
2338 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2339 VALUES ('src', 'id', NULL, NULL)",
2340 [],
2341 );
2342 assert!(e.is_err());
2343 let e = conn.execute(
2345 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2346 VALUES ('src', 'id', 'src', 'slug')",
2347 [],
2348 );
2349 assert!(e.is_err());
2350 let e = conn.execute(
2352 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2353 VALUES ('src', 'name', NULL, NULL)",
2354 [],
2355 );
2356 assert!(e.is_err());
2357 }
2358
2359 #[test]
2360 fn schema_name_links_table_does_not_exist() {
2361 let conn = setup();
2362 let n: i64 = conn
2363 .query_row(
2364 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
2365 [],
2366 |r| r.get(0),
2367 )
2368 .unwrap();
2369 assert_eq!(n, 0, "pre-consolidation name_links table must not exist");
2370 }
2371
2372 #[test]
2373 fn schema_links_target_index_present() {
2374 let conn = setup();
2375 let n: i64 = conn
2376 .query_row(
2377 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='links_target_idx' AND tbl_name='links'",
2378 [],
2379 |r| r.get(0),
2380 )
2381 .unwrap();
2382 assert_eq!(n, 1);
2383 }
2384
2385 #[test]
2386 fn schema_audit_log_columns() {
2387 let conn = setup();
2388 let cols: Vec<(String, String, i64, i64)> = conn
2389 .prepare("PRAGMA table_info(audit_log)")
2390 .unwrap()
2391 .query_map([], |r| {
2392 Ok((
2393 r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(5)?, ))
2398 })
2399 .unwrap()
2400 .collect::<Result<_, _>>()
2401 .unwrap();
2402 assert_eq!(
2403 cols,
2404 vec![
2405 ("id".into(), "INTEGER".into(), 0, 1),
2406 ("node_id".into(), "TEXT".into(), 1, 0),
2407 ("operation".into(), "TEXT".into(), 1, 0),
2408 ("old_blob".into(), "TEXT".into(), 0, 0),
2409 ("new_blob".into(), "TEXT".into(), 0, 0),
2410 ("timestamp".into(), "TEXT".into(), 1, 0),
2411 ]
2412 );
2413 }
2414
2415 #[test]
2416 fn schema_audit_log_has_no_foreign_keys() {
2417 let conn = setup();
2418 let n: i64 = conn
2419 .query_row(
2420 "SELECT COUNT(*) FROM pragma_foreign_key_list('audit_log')",
2421 [],
2422 |r| r.get(0),
2423 )
2424 .unwrap();
2425 assert_eq!(n, 0);
2426 }
2427
2428 #[test]
2429 fn schema_audit_log_autoincrement_works() {
2430 let conn = setup();
2431 conn.execute(
2432 "INSERT INTO audit_log (node_id, operation, timestamp) VALUES ('x', 'insert', 'now')",
2433 [],
2434 )
2435 .unwrap();
2436 conn.execute(
2437 "INSERT INTO audit_log (node_id, operation, timestamp) VALUES ('y', 'insert', 'now')",
2438 [],
2439 )
2440 .unwrap();
2441 let ids: Vec<i64> = conn
2442 .prepare("SELECT id FROM audit_log ORDER BY id")
2443 .unwrap()
2444 .query_map([], |r| r.get::<_, i64>(0))
2445 .unwrap()
2446 .collect::<Result<_, _>>()
2447 .unwrap();
2448 assert_eq!(ids, vec![1, 2]);
2449 let n: i64 = conn
2451 .query_row(
2452 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'",
2453 [],
2454 |r| r.get(0),
2455 )
2456 .unwrap();
2457 assert_eq!(n, 1);
2458 }
2459
2460 #[test]
2461 fn schema_audit_log_indices_present() {
2462 let conn = setup();
2463 for name in ["audit_log_node_idx", "audit_log_ts_idx"] {
2464 let n: i64 = conn
2465 .query_row(
2466 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?1 AND tbl_name='audit_log'",
2467 params![name],
2468 |r| r.get(0),
2469 )
2470 .unwrap();
2471 assert_eq!(n, 1, "missing index {name}");
2472 }
2473 }
2474
2475 fn fetch_audit_rows(conn: &Connection, node_id: &str) -> Vec<AuditRow> {
2478 conn.prepare(
2479 "SELECT id, node_id, operation, old_blob, new_blob, timestamp FROM audit_log WHERE node_id = ?1 ORDER BY id",
2480 )
2481 .unwrap()
2482 .query_map(params![node_id], |r| {
2483 Ok(AuditRow {
2484 id: r.get(0)?,
2485 node_id: r.get(1)?,
2486 operation: r.get(2)?,
2487 old_blob: r.get(3)?,
2488 new_blob: r.get(4)?,
2489 timestamp: r.get(5)?,
2490 })
2491 })
2492 .unwrap()
2493 .collect::<Result<_, _>>()
2494 .unwrap()
2495 }
2496
2497 #[test]
2498 fn audit_writers_insert_writes_one_row() {
2499 let conn = setup();
2500 insert_node(&conn, "n", &sample_doc()).unwrap();
2501 let rows = fetch_audit_rows(&conn, "n");
2502 assert_eq!(rows.len(), 1);
2503 assert_eq!(rows[0].operation, "insert");
2504 assert!(rows[0].old_blob.is_none());
2505 assert!(
2506 rows[0]
2507 .new_blob
2508 .as_deref()
2509 .unwrap()
2510 .starts_with("(kb-doc 1")
2511 );
2512 let ts = &rows[0].timestamp;
2514 assert!(
2515 ts.ends_with('Z') && ts.contains('.'),
2516 "timestamp not ISO8601-ms: {ts}"
2517 );
2518 }
2519
2520 #[test]
2521 fn audit_writers_update_records_old_and_new() {
2522 let conn = setup();
2523 insert_node(&conn, "n", &sample_doc()).unwrap();
2524 let mut next = sample_doc();
2525 next.blocks.push(Block::Paragraph {
2526 inlines: vec![Inline::Plain("more".into())],
2527 });
2528 update_node(&conn, "n", &next).unwrap();
2529 let rows = fetch_audit_rows(&conn, "n");
2530 assert_eq!(rows.len(), 2);
2531 assert_eq!(rows[1].operation, "update");
2532 let old = rows[1].old_blob.as_deref().unwrap();
2533 let new = rows[1].new_blob.as_deref().unwrap();
2534 assert_ne!(old, new);
2535 assert!(new.contains("more"));
2536 }
2537
2538 #[test]
2539 fn audit_writers_delete_records_old_only() {
2540 let conn = setup();
2541 insert_node(&conn, "n", &sample_doc()).unwrap();
2542 delete_node(&conn, "n").unwrap();
2543 let rows = fetch_audit_rows(&conn, "n");
2544 assert_eq!(rows.len(), 2);
2546 assert_eq!(rows[1].operation, "delete");
2547 assert!(rows[1].old_blob.is_some());
2548 assert!(rows[1].new_blob.is_none());
2549 }
2550
2551 #[test]
2552 fn audit_writers_timestamp_format_matches_node_created_at() {
2553 let conn = setup();
2554 insert_node(&conn, "n", &sample_doc()).unwrap();
2555 let node_ts: String = conn
2556 .query_row("SELECT created_at FROM nodes WHERE id = 'n'", [], |r| {
2557 r.get(0)
2558 })
2559 .unwrap();
2560 let audit_ts: String = conn
2561 .query_row(
2562 "SELECT timestamp FROM audit_log WHERE node_id = 'n'",
2563 [],
2564 |r| r.get(0),
2565 )
2566 .unwrap();
2567 assert!(is_iso8601_ms_z(&node_ts), "node ts: {node_ts}");
2568 assert!(is_iso8601_ms_z(&audit_ts), "audit ts: {audit_ts}");
2569 }
2570
2571 fn is_iso8601_ms_z(s: &str) -> bool {
2573 let bytes = s.as_bytes();
2574 if bytes.len() != 24 {
2575 return false;
2576 }
2577 let digits =
2593 |range: std::ops::Range<usize>| range.into_iter().all(|i| bytes[i].is_ascii_digit());
2594 digits(0..4)
2595 && bytes[4] == b'-'
2596 && digits(5..7)
2597 && bytes[7] == b'-'
2598 && digits(8..10)
2599 && bytes[10] == b'T'
2600 && digits(11..13)
2601 && bytes[13] == b':'
2602 && digits(14..16)
2603 && bytes[16] == b':'
2604 && digits(17..19)
2605 && bytes[19] == b'.'
2606 && digits(20..23)
2607 && bytes[23] == b'Z'
2608 }
2609
2610 #[test]
2617 fn extract_links_walks_nested_block_positions() {
2618 let inner_link = Inline::Link {
2619 target: "id:nested".into(),
2620 description: Some("nested".into()),
2621 };
2622 let doc = Document {
2623 blocks: vec![
2624 Block::Heading {
2625 level: 1,
2626 title: Title("h".into()),
2627 tags: vec![],
2628 children: vec![Block::Paragraph {
2629 inlines: vec![Inline::Bold(vec![inner_link])],
2630 }],
2631 },
2632 Block::QuoteBlock {
2633 children: vec![Block::Paragraph {
2634 inlines: vec![Inline::Italic(vec![Inline::Link {
2635 target: "id:in_quote".into(),
2636 description: None,
2637 }])],
2638 }],
2639 },
2640 Block::List {
2641 list_type: ListType::Unordered,
2642 items: vec![ListItem {
2643 content: vec![Block::Paragraph {
2644 inlines: vec![Inline::Link {
2645 target: "id:in_list".into(),
2646 description: None,
2647 }],
2648 }],
2649 checkbox: Checkbox::NoCheckbox,
2650 }],
2651 },
2652 Block::Table {
2653 rows: vec![vec![TableCell {
2654 inlines: vec![Inline::Link {
2655 target: "id:in_table".into(),
2656 description: None,
2657 }],
2658 }]],
2659 },
2660 ],
2661 };
2662 let ids: Vec<String> = extract_links(&doc)
2663 .into_iter()
2664 .filter_map(|l| match l {
2665 LinkRef::Id(s) => Some(s),
2666 LinkRef::Name(_) => None,
2667 })
2668 .collect();
2669 assert_eq!(
2670 ids,
2671 vec![
2672 "nested".to_string(),
2673 "in_quote".to_string(),
2674 "in_list".to_string(),
2675 "in_table".to_string(),
2676 ]
2677 );
2678 }
2679
2680 fn empty_doc() -> Document {
2683 Document { blocks: vec![] }
2684 }
2685
2686 #[test]
2687 fn relink_one_inserts_existing_targets() {
2688 let conn = setup();
2689 insert_node(&conn, "src", &empty_doc()).unwrap();
2690 insert_node(&conn, "tgt1", &empty_doc()).unwrap();
2691 insert_node(&conn, "tgt2", &empty_doc()).unwrap();
2692 relink_one(&conn, "src", &link_doc(&["tgt1", "tgt2"])).unwrap();
2693
2694 let mut rows: Vec<(String, String)> = conn
2695 .prepare("SELECT target_id, link_type FROM links WHERE source_id = 'src'")
2696 .unwrap()
2697 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
2698 .unwrap()
2699 .collect::<Result<_, _>>()
2700 .unwrap();
2701 rows.sort();
2702 assert_eq!(
2703 rows,
2704 vec![
2705 ("tgt1".to_string(), "id".to_string()),
2706 ("tgt2".to_string(), "id".to_string()),
2707 ]
2708 );
2709 }
2710
2711 #[test]
2712 fn relink_one_drops_forward_refs() {
2713 let conn = setup();
2714 insert_node(&conn, "src", &empty_doc()).unwrap();
2715 relink_one(&conn, "src", &link_doc(&["tgt-missing"])).unwrap();
2717 let n: i64 = conn
2718 .query_row(
2719 "SELECT COUNT(*) FROM links WHERE source_id = 'src'",
2720 [],
2721 |r| r.get(0),
2722 )
2723 .unwrap();
2724 assert_eq!(n, 0);
2725 }
2726
2727 #[test]
2728 fn relink_one_replaces_prior_outgoing() {
2729 let conn = setup();
2730 insert_node(&conn, "src", &empty_doc()).unwrap();
2731 insert_node(&conn, "old", &empty_doc()).unwrap();
2732 insert_node(&conn, "new", &empty_doc()).unwrap();
2733 relink_one(&conn, "src", &link_doc(&["old"])).unwrap();
2734 relink_one(&conn, "src", &link_doc(&["new"])).unwrap();
2735 let rows: Vec<String> = conn
2736 .prepare("SELECT target_id FROM links WHERE source_id = 'src'")
2737 .unwrap()
2738 .query_map([], |r| r.get::<_, String>(0))
2739 .unwrap()
2740 .collect::<Result<_, _>>()
2741 .unwrap();
2742 assert_eq!(rows, vec!["new".to_string()]);
2743 }
2744
2745 #[test]
2748 fn relink_all_resolves_forward_refs() {
2749 let conn = setup();
2750 insert_node(&conn, "a", &link_doc(&["b"])).unwrap();
2752 insert_node(&conn, "b", &empty_doc()).unwrap();
2753 let n_before: i64 = conn
2755 .query_row(
2756 "SELECT COUNT(*) FROM links WHERE source_id = 'a'",
2757 [],
2758 |r| r.get(0),
2759 )
2760 .unwrap();
2761 assert_eq!(n_before, 0);
2762
2763 let (nodes, links) = relink_all(&conn).unwrap();
2764 assert_eq!(nodes, 2);
2765 assert_eq!(links, 1);
2766 let rows: Vec<String> = conn
2767 .prepare("SELECT target_id FROM links WHERE source_id = 'a'")
2768 .unwrap()
2769 .query_map([], |r| r.get::<_, String>(0))
2770 .unwrap()
2771 .collect::<Result<_, _>>()
2772 .unwrap();
2773 assert_eq!(rows, vec!["b".to_string()]);
2774 }
2775
2776 #[test]
2777 fn relink_all_skips_corrupt_blob() {
2778 let conn = setup();
2779 insert_node(&conn, "good", &empty_doc()).unwrap();
2780 conn.execute(
2782 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) VALUES ('bad', 'x', 'not-a-sexp', '0', '0')",
2783 [],
2784 )
2785 .unwrap();
2786 let (nodes, links) = relink_all(&conn).unwrap();
2787 assert_eq!(nodes, 2);
2788 assert_eq!(links, 0);
2789 }
2790
2791 #[test]
2792 fn relink_all_clears_stale_links_first() {
2793 let conn = setup();
2794 insert_node(&conn, "src", &empty_doc()).unwrap();
2795 insert_node(&conn, "tgt", &empty_doc()).unwrap();
2796 conn.execute(
2798 "INSERT INTO links (source_id, target_id, link_type) VALUES ('src', 'tgt', 'id')",
2799 [],
2800 )
2801 .unwrap();
2802 let (_, links) = relink_all(&conn).unwrap();
2805 assert_eq!(links, 0);
2806 let n: i64 = conn
2807 .query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
2808 .unwrap();
2809 assert_eq!(n, 0);
2810 }
2811
2812 #[test]
2815 fn neighborhood_unknown_id_is_empty() {
2816 let conn = setup();
2817 let n = get_neighborhood(&conn, "nope").unwrap();
2818 assert!(n.outgoing.is_empty());
2819 assert!(n.incoming.is_empty());
2820 }
2821
2822 #[test]
2823 fn neighborhood_returns_outgoing_and_incoming() {
2824 let conn = setup();
2825 insert_node(&conn, "a", &empty_doc()).unwrap();
2826 insert_node(&conn, "b", &empty_doc()).unwrap();
2827 insert_node(&conn, "c", &empty_doc()).unwrap();
2828 relink_one(&conn, "a", &link_doc(&["b"])).unwrap();
2830 relink_one(&conn, "c", &link_doc(&["a"])).unwrap();
2831
2832 let n = get_neighborhood(&conn, "a").unwrap();
2833 assert_eq!(n.outgoing.len(), 1);
2834 assert_eq!(n.outgoing[0].0.0, "b");
2835 assert_eq!(n.outgoing[0].1, LinkType::Id);
2836 assert_eq!(n.incoming.len(), 1);
2837 assert_eq!(n.incoming[0].0.0, "c");
2838 assert_eq!(n.incoming[0].1, LinkType::Id);
2839 }
2840
2841 #[test]
2844 fn list_all_nodes_zero_limit_empty() {
2845 let conn = setup();
2846 insert_node(&conn, "a", &sample_doc()).unwrap();
2847 let v = list_all_nodes(&conn, 0, 0).unwrap();
2848 assert!(v.is_empty());
2849 }
2850
2851 #[test]
2852 fn list_all_nodes_orders_by_updated_at_desc() {
2853 let conn = setup();
2854 insert_node(&conn, "first", &sample_doc()).unwrap();
2855 std::thread::sleep(std::time::Duration::from_millis(10));
2856 insert_node(&conn, "second", &sample_doc()).unwrap();
2857 std::thread::sleep(std::time::Duration::from_millis(10));
2858 insert_node(&conn, "third", &sample_doc()).unwrap();
2859
2860 let v = list_all_nodes(&conn, 10, 0).unwrap();
2861 let ids: Vec<String> = v.iter().map(|(n, _)| n.0.clone()).collect();
2862 assert_eq!(ids, vec!["third", "second", "first"]);
2863 }
2864
2865 #[test]
2866 fn list_all_nodes_paginates() {
2867 let conn = setup();
2868 for i in 0..5 {
2869 insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
2870 std::thread::sleep(std::time::Duration::from_millis(2));
2871 }
2872 let page1 = list_all_nodes(&conn, 2, 0).unwrap();
2873 let page2 = list_all_nodes(&conn, 2, 2).unwrap();
2874 assert_eq!(page1.len(), 2);
2875 assert_eq!(page2.len(), 2);
2876 for (id1, _) in &page1 {
2878 for (id2, _) in &page2 {
2879 assert_ne!(id1.0, id2.0);
2880 }
2881 }
2882 }
2883
2884 #[test]
2887 fn write_path_relinks_on_insert() {
2888 let conn = setup();
2889 insert_node(&conn, "tgt", &empty_doc()).unwrap();
2890 insert_node(&conn, "src", &link_doc(&["tgt"])).unwrap();
2891 let n: i64 = conn
2892 .query_row(
2893 "SELECT COUNT(*) FROM links WHERE source_id = 'src' AND target_id = 'tgt'",
2894 [],
2895 |r| r.get(0),
2896 )
2897 .unwrap();
2898 assert_eq!(n, 1);
2899 }
2900
2901 #[test]
2902 fn write_path_relinks_on_update() {
2903 let conn = setup();
2904 insert_node(&conn, "tgt1", &empty_doc()).unwrap();
2905 insert_node(&conn, "tgt2", &empty_doc()).unwrap();
2906 insert_node(&conn, "src", &link_doc(&["tgt1"])).unwrap();
2907 update_node(&conn, "src", &link_doc(&["tgt2"])).unwrap();
2908 let rows: Vec<String> = conn
2909 .prepare("SELECT target_id FROM links WHERE source_id = 'src'")
2910 .unwrap()
2911 .query_map([], |r| r.get::<_, String>(0))
2912 .unwrap()
2913 .collect::<Result<_, _>>()
2914 .unwrap();
2915 assert_eq!(rows, vec!["tgt2".to_string()]);
2916 }
2917
2918 #[test]
2919 fn write_path_relinks_drop_forward_refs_silently() {
2920 let conn = setup();
2921 insert_node(&conn, "src", &link_doc(&["future"])).unwrap();
2923 let n: i64 = conn
2924 .query_row(
2925 "SELECT COUNT(*) FROM links WHERE source_id = 'src'",
2926 [],
2927 |r| r.get(0),
2928 )
2929 .unwrap();
2930 assert_eq!(n, 0);
2931 insert_node(&conn, "future", &empty_doc()).unwrap();
2933 let (_, links) = relink_all(&conn).unwrap();
2934 assert_eq!(links, 1);
2935 }
2936
2937 #[test]
2940 fn schema_embeddings_table_columns_and_pk() {
2941 let conn = setup();
2942 let cols: Vec<(String, String, i64, i64)> = conn
2943 .prepare("PRAGMA table_info(embeddings)")
2944 .unwrap()
2945 .query_map([], |r| {
2946 Ok((
2947 r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, i64>(5)?, ))
2952 })
2953 .unwrap()
2954 .collect::<Result<_, _>>()
2955 .unwrap();
2956 assert_eq!(
2957 cols,
2958 vec![
2959 ("node_id".into(), "TEXT".into(), 1, 1),
2960 ("model".into(), "TEXT".into(), 1, 2),
2961 ("embedding".into(), "BLOB".into(), 1, 0),
2962 ]
2963 );
2964 }
2965
2966 #[test]
2967 fn schema_embeddings_table_foreign_keys() {
2968 let conn = setup();
2969 let fks: Vec<(String, String, String, String)> = conn
2970 .prepare("PRAGMA foreign_key_list(embeddings)")
2971 .unwrap()
2972 .query_map([], |r| {
2973 Ok((
2974 r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?, r.get::<_, String>(6)?, ))
2979 })
2980 .unwrap()
2981 .collect::<Result<_, _>>()
2982 .unwrap();
2983 assert_eq!(fks.len(), 1);
2984 let (table, from, to, on_delete) = &fks[0];
2985 assert_eq!(table, "nodes");
2986 assert_eq!(from, "node_id");
2987 assert_eq!(to, "id");
2988 assert_eq!(on_delete, "CASCADE");
2989 }
2990
2991 #[test]
2992 fn schema_embeddings_model_index_present() {
2993 let conn = setup();
2994 let n: i64 = conn
2995 .query_row(
2996 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='embeddings_model_idx' AND tbl_name='embeddings'",
2997 [],
2998 |r| r.get(0),
2999 )
3000 .unwrap();
3001 assert_eq!(n, 1);
3002 }
3003
3004 #[test]
3005 fn schema_embeddings_version_unchanged() {
3006 let conn = setup();
3009 let v: i64 = conn
3010 .query_row("PRAGMA user_version", [], |r| r.get(0))
3011 .unwrap();
3012 assert_eq!(v, i64::from(CURRENT_SCHEMA_VERSION));
3013 }
3014
3015 #[test]
3016 fn schema_embeddings_table_cascades_on_node_delete() {
3017 let conn = setup();
3018 insert_node(&conn, "n", &sample_doc()).unwrap();
3019 conn.execute(
3020 "INSERT INTO embeddings (node_id, model, embedding) VALUES ('n', 'm', X'00000000')",
3021 [],
3022 )
3023 .unwrap();
3024 delete_node(&conn, "n").unwrap();
3025 let n: i64 = conn
3026 .query_row(
3027 "SELECT COUNT(*) FROM embeddings WHERE node_id = 'n'",
3028 [],
3029 |r| r.get(0),
3030 )
3031 .unwrap();
3032 assert_eq!(n, 0, "FOREIGN KEY ON DELETE CASCADE must clear embeddings");
3033 }
3034
3035 #[test]
3038 fn cosine_similarity_identical_vectors_is_one() {
3039 let v = vec![1.0_f32, 2.0, 3.0];
3040 let s = cosine_similarity(&v, &v);
3041 assert!((s - 1.0).abs() < 1e-6, "got {s}");
3042 }
3043
3044 #[test]
3045 fn cosine_similarity_orthogonal_vectors_is_zero() {
3046 let s = cosine_similarity(&[1.0_f32, 0.0], &[0.0, 1.0]);
3047 assert!(s.abs() < 1e-6, "got {s}");
3048 }
3049
3050 #[test]
3051 fn cosine_similarity_opposite_vectors_is_negative_one() {
3052 let s = cosine_similarity(&[1.0_f32, 0.0], &[-1.0, 0.0]);
3053 assert!((s + 1.0).abs() < 1e-6, "got {s}");
3054 }
3055
3056 #[test]
3057 fn cosine_similarity_length_mismatch_returns_zero() {
3058 let s = cosine_similarity(&[1.0_f32, 2.0], &[1.0, 2.0, 3.0]);
3059 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3060 }
3061
3062 #[test]
3063 fn cosine_similarity_zero_norm_returns_zero() {
3064 let s = cosine_similarity(&[0.0_f32, 0.0, 0.0], &[1.0, 2.0, 3.0]);
3065 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3066 let s = cosine_similarity(&[1.0_f32, 2.0], &[0.0, 0.0]);
3067 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3068 }
3069
3070 #[test]
3071 fn cosine_similarity_empty_vectors_is_zero() {
3072 let s = cosine_similarity(&[], &[]);
3074 assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3075 }
3076
3077 #[test]
3080 fn rrf_empty_lists_yields_empty() {
3081 let out = reciprocal_rank_fusion(60, &[]);
3082 assert!(out.is_empty());
3083 let out = reciprocal_rank_fusion(60, &[Vec::<NodeId>::new(), Vec::<NodeId>::new()]);
3084 assert!(out.is_empty());
3085 }
3086
3087 #[test]
3088 fn rrf_single_list_preserves_order() {
3089 let l = vec![NodeId("a".into()), NodeId("b".into()), NodeId("c".into())];
3090 let out = reciprocal_rank_fusion(60, std::slice::from_ref(&l));
3091 assert_eq!(out, l);
3092 }
3093
3094 #[test]
3095 fn rrf_two_lists_combine_scores() {
3096 let l1 = vec![NodeId("a".into()), NodeId("b".into())];
3097 let l2 = vec![NodeId("b".into()), NodeId("a".into())];
3098 let out = reciprocal_rank_fusion(60, &[l1, l2]);
3103 assert_eq!(out, vec![NodeId("a".into()), NodeId("b".into())]);
3104 }
3105
3106 #[test]
3107 fn rrf_node_only_in_one_list_ranks_lower() {
3108 let l1 = vec![NodeId("a".into()), NodeId("b".into())];
3110 let l2 = vec![NodeId("b".into())];
3111 let out = reciprocal_rank_fusion(60, &[l1, l2]);
3115 assert_eq!(out, vec![NodeId("b".into()), NodeId("a".into())]);
3116 }
3117
3118 #[test]
3119 fn rrf_ties_break_by_ascending_id() {
3120 let l1 = vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())];
3121 let l2 = vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())];
3122 let out = reciprocal_rank_fusion(60, &[l1, l2]);
3130 assert_eq!(
3131 out,
3132 vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())]
3133 );
3134 }
3135
3136 #[test]
3137 fn rrf_truly_tied_scores_break_by_ascending_id() {
3138 let l1 = vec![NodeId("z".into())]; let l2 = vec![NodeId("a".into())]; let out = reciprocal_rank_fusion(60, &[l1, l2]);
3144 assert_eq!(out, vec![NodeId("a".into()), NodeId("z".into())]);
3146 }
3147
3148 #[test]
3151 fn vec_extension_missing_path_returns_err() {
3152 let conn = setup();
3153 let err = try_load_vec_extension(&conn, "/nonexistent/path/to/sqlite-vec.dylib")
3154 .expect_err("loading a missing path must fail");
3155 assert!(matches!(err, VecExtensionError::LoadExtension(_)));
3156 }
3157
3158 #[test]
3159 fn vec_extension_failure_leaves_connection_usable() {
3160 let conn = setup();
3161 let _ = try_load_vec_extension(&conn, "/nonexistent/path/to/sqlite-vec.dylib");
3162 insert_node(&conn, "after-fail", &sample_doc()).unwrap();
3164 let doc = get_node(&conn, "after-fail").unwrap().unwrap();
3165 assert_eq!(doc, sample_doc());
3166 }
3167
3168 fn count_embeddings(conn: &Connection, model: &str) -> i64 {
3171 conn.query_row(
3172 "SELECT COUNT(*) FROM embeddings WHERE model = ?1",
3173 params![model],
3174 |r| r.get(0),
3175 )
3176 .unwrap()
3177 }
3178
3179 #[test]
3184 fn storage_precomputed_embedding_inserts_one_row_on_insert() {
3185 let conn = setup();
3186 insert_node_with(
3187 &conn,
3188 "n",
3189 &sample_doc(),
3190 Some(vec![1.0, 2.0, 3.0]),
3191 Some("test-model"),
3192 )
3193 .unwrap();
3194 assert_eq!(count_embeddings(&conn, "test-model"), 1);
3195 let blob: Vec<u8> = conn
3196 .query_row(
3197 "SELECT embedding FROM embeddings WHERE node_id = 'n' AND model = 'test-model'",
3198 [],
3199 |r| r.get(0),
3200 )
3201 .unwrap();
3202 let decoded = embedding::decode_embedding(&blob).unwrap();
3203 assert_eq!(decoded, vec![1.0_f32, 2.0, 3.0]);
3204 }
3205
3206 #[test]
3207 fn storage_precomputed_embedding_replaces_on_update() {
3208 let conn = setup();
3209 insert_node_with(
3210 &conn,
3211 "n",
3212 &sample_doc(),
3213 Some(vec![1.0, 0.0]),
3214 Some("test-model"),
3215 )
3216 .unwrap();
3217 let mut doc = sample_doc();
3218 doc.blocks.push(Block::Paragraph {
3219 inlines: vec![Inline::Plain("more".into())],
3220 });
3221 update_node_with(&conn, "n", &doc, Some(vec![0.0, 1.0]), Some("test-model")).unwrap();
3222 assert_eq!(count_embeddings(&conn, "test-model"), 1);
3223 let blob: Vec<u8> = conn
3224 .query_row(
3225 "SELECT embedding FROM embeddings WHERE node_id = 'n'",
3226 [],
3227 |r| r.get(0),
3228 )
3229 .unwrap();
3230 assert_eq!(
3231 embedding::decode_embedding(&blob).unwrap(),
3232 vec![0.0_f32, 1.0]
3233 );
3234 }
3235
3236 #[test]
3237 fn storage_precomputed_embedding_skipped_without_vector() {
3238 let conn = setup();
3239 insert_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3240 update_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3241 assert_eq!(
3242 conn.query_row::<i64, _, _>("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3243 .unwrap(),
3244 0
3245 );
3246 }
3247
3248 #[test]
3249 fn storage_precomputed_embedding_skipped_without_model() {
3250 let conn = setup();
3253 insert_node_with(&conn, "n", &sample_doc(), Some(vec![1.0]), None).unwrap();
3254 assert_eq!(
3255 conn.query_row::<i64, _, _>("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3256 .unwrap(),
3257 0
3258 );
3259 }
3260
3261 #[test]
3262 fn storage_precomputed_embedding_keys_by_node_and_model() {
3263 let conn = setup();
3264 insert_node_with(
3265 &conn,
3266 "n",
3267 &sample_doc(),
3268 Some(vec![1.0, 0.0]),
3269 Some("model-a"),
3270 )
3271 .unwrap();
3272 update_node_with(
3273 &conn,
3274 "n",
3275 &sample_doc(),
3276 Some(vec![0.0, 1.0]),
3277 Some("model-b"),
3278 )
3279 .unwrap();
3280 assert_eq!(count_embeddings(&conn, "model-a"), 1);
3281 assert_eq!(count_embeddings(&conn, "model-b"), 1);
3282 }
3283
3284 #[test]
3287 fn search_hybrid_no_query_embedding_matches_search_fts() {
3288 let conn = setup();
3289 let doc1 = Document {
3290 blocks: vec![Block::Heading {
3291 level: 1,
3292 title: Title("Rust".into()),
3293 tags: vec![],
3294 children: vec![Block::Paragraph {
3295 inlines: vec![Inline::Plain("systems language".into())],
3296 }],
3297 }],
3298 };
3299 let doc2 = Document {
3300 blocks: vec![Block::Heading {
3301 level: 1,
3302 title: Title("Python".into()),
3303 tags: vec![],
3304 children: vec![Block::Paragraph {
3305 inlines: vec![Inline::Plain("scripting language".into())],
3306 }],
3307 }],
3308 };
3309 insert_node(&conn, "a", &doc1).unwrap();
3310 insert_node(&conn, "b", &doc2).unwrap();
3311 let fts: Vec<NodeId> = search_fts(&conn, "language")
3312 .unwrap()
3313 .into_iter()
3314 .map(NodeId)
3315 .collect();
3316 let hybrid = search_hybrid(&conn, "language", None).unwrap();
3317 assert_eq!(hybrid, fts);
3318 }
3319
3320 #[test]
3321 fn search_hybrid_empty_query_yields_empty() {
3322 let conn = setup();
3323 insert_node(&conn, "a", &sample_doc()).unwrap();
3324 assert!(search_hybrid(&conn, "", None).unwrap().is_empty());
3326 assert!(
3329 search_hybrid(&conn, " ", Some((vec![1.0], "stub")))
3330 .unwrap()
3331 .is_empty()
3332 );
3333 }
3334
3335 #[test]
3336 fn search_hybrid_with_query_embedding_combines_keyword_and_vector_rankings() {
3337 let conn = setup();
3338 insert_node(
3339 &conn,
3340 "alpha",
3341 &Document {
3342 blocks: vec![Block::Heading {
3343 level: 1,
3344 title: Title("alpha note".into()),
3345 tags: vec![],
3346 children: vec![Block::Paragraph {
3347 inlines: vec![Inline::Plain("the quick brown fox".into())],
3348 }],
3349 }],
3350 },
3351 )
3352 .unwrap();
3353 insert_node(
3354 &conn,
3355 "beta",
3356 &Document {
3357 blocks: vec![Block::Heading {
3358 level: 1,
3359 title: Title("beta note".into()),
3360 tags: vec![],
3361 children: vec![Block::Paragraph {
3362 inlines: vec![Inline::Plain("a different idea entirely".into())],
3363 }],
3364 }],
3365 },
3366 )
3367 .unwrap();
3368 let alpha_vec: Vec<f32> = vec![1.0, 0.0];
3369 let beta_vec: Vec<f32> = vec![0.0, 1.0];
3370 for (id, v) in [("alpha", &alpha_vec), ("beta", &beta_vec)] {
3371 conn.execute(
3372 "INSERT INTO embeddings (node_id, model, embedding) VALUES (?1, 'hybrid-model', ?2)",
3373 params![id, embedding::encode_embedding(v)],
3374 )
3375 .unwrap();
3376 }
3377 let out =
3380 search_hybrid(&conn, "alpha", Some((vec![0.0_f32, 1.0], "hybrid-model"))).unwrap();
3381 assert_eq!(out, vec![NodeId("alpha".into()), NodeId("beta".into())]);
3387 }
3388
3389 #[test]
3390 fn search_hybrid_with_query_embedding_returns_vector_only_when_fts_empty() {
3391 let conn = setup();
3392 insert_node(&conn, "a", &sample_doc()).unwrap();
3393 conn.execute(
3394 "INSERT INTO embeddings (node_id, model, embedding) VALUES ('a', 'm', ?1)",
3395 params![embedding::encode_embedding(&[1.0_f32])],
3396 )
3397 .unwrap();
3398 let out = search_hybrid(&conn, "nomatch", Some((vec![1.0_f32], "m"))).unwrap();
3401 assert_eq!(out, vec![NodeId("a".into())]);
3402 }
3403
3404 #[test]
3407 fn embedding_disabled_writes_succeed() {
3408 let conn = setup();
3409 insert_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3410 update_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3411 delete_node(&conn, "n").unwrap();
3412 assert!(get_node(&conn, "n").unwrap().is_none());
3413 }
3414
3415 #[test]
3416 fn embedding_disabled_keeps_embeddings_table_empty() {
3417 let conn = setup();
3418 insert_node(&conn, "a", &sample_doc()).unwrap();
3419 insert_node(&conn, "b", &sample_doc()).unwrap();
3420 let n: i64 = conn
3421 .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3422 .unwrap();
3423 assert_eq!(n, 0);
3424 }
3425
3426 #[test]
3427 fn embedding_disabled_search_hybrid_degrades_to_search_fts() {
3428 let conn = setup();
3429 let doc = Document {
3430 blocks: vec![Block::Heading {
3431 level: 1,
3432 title: Title("hello".into()),
3433 tags: vec![],
3434 children: vec![Block::Paragraph {
3435 inlines: vec![Inline::Plain("world".into())],
3436 }],
3437 }],
3438 };
3439 insert_node(&conn, "a", &doc).unwrap();
3440 let fts: Vec<NodeId> = search_fts(&conn, "hello")
3441 .unwrap()
3442 .into_iter()
3443 .map(NodeId)
3444 .collect();
3445 let hybrid = search_hybrid(&conn, "hello", None).unwrap();
3446 assert_eq!(hybrid, fts);
3447 }
3448
3449 fn named_doc(slug: &str, body: &str) -> Document {
3453 Document {
3454 blocks: vec![
3455 Block::Keyword {
3456 name: "name".into(),
3457 value: format!(" {slug}"),
3458 },
3459 Block::Paragraph {
3460 inlines: vec![Inline::Plain(body.into())],
3461 },
3462 ],
3463 }
3464 }
3465
3466 fn referencing_doc(slugs: &[&str]) -> Document {
3468 let inlines: Vec<Inline> = slugs
3469 .iter()
3470 .map(|s| Inline::Link {
3471 target: (*s).to_string(),
3472 description: None,
3473 })
3474 .collect();
3475 Document {
3476 blocks: vec![Block::Paragraph { inlines }],
3477 }
3478 }
3479
3480 fn count_links_total(conn: &Connection) -> i64 {
3481 conn.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
3482 .unwrap()
3483 }
3484
3485 fn fetch_name_link(conn: &Connection, src_id: &str, slug: &str) -> Option<LinkRow> {
3487 conn.query_row(
3488 "SELECT source_id, link_type, target_id, target_slug FROM links \
3489 WHERE source_id = ?1 AND link_type = 'name' AND target_slug = ?2",
3490 params![src_id, slug],
3491 |r| {
3492 Ok(LinkRow {
3493 source_id: r.get(0)?,
3494 link_type: r.get(1)?,
3495 target_id: r.get(2)?,
3496 target_slug: r.get(3)?,
3497 })
3498 },
3499 )
3500 .optional()
3501 .unwrap()
3502 }
3503
3504 #[test]
3508 #[allow(
3509 clippy::too_many_lines,
3510 reason = "cohesive schema-parity assertion suite; splitting would not improve clarity"
3511 )]
3512 fn links_table_unified_schema() {
3513 let conn = setup();
3514 let n_links: i64 = conn
3516 .query_row(
3517 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='links'",
3518 [],
3519 |r| r.get(0),
3520 )
3521 .unwrap();
3522 assert_eq!(n_links, 1);
3523 let n_name_links: i64 = conn
3524 .query_row(
3525 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
3526 [],
3527 |r| r.get(0),
3528 )
3529 .unwrap();
3530 assert_eq!(n_name_links, 0, "name_links table must not exist");
3531
3532 let n_view: i64 = conn
3534 .query_row(
3535 "SELECT COUNT(*) FROM sqlite_master WHERE type='view' AND name='name_links'",
3536 [],
3537 |r| r.get(0),
3538 )
3539 .unwrap();
3540 assert_eq!(n_view, 0, "name_links view must not exist");
3541
3542 let cols: Vec<(String, String, i64, i64)> = conn
3544 .prepare("PRAGMA table_info(links)")
3545 .unwrap()
3546 .query_map([], |r| {
3547 Ok((
3548 r.get::<_, String>(1)?,
3549 r.get::<_, String>(2)?,
3550 r.get::<_, i64>(3)?,
3551 r.get::<_, i64>(5)?,
3552 ))
3553 })
3554 .unwrap()
3555 .collect::<Result<_, _>>()
3556 .unwrap();
3557 assert_eq!(
3558 cols,
3559 vec![
3560 ("source_id".into(), "TEXT".into(), 1, 1),
3561 ("link_type".into(), "TEXT".into(), 1, 2),
3562 ("target_id".into(), "TEXT".into(), 0, 3),
3563 ("target_slug".into(), "TEXT".into(), 0, 4),
3564 ]
3565 );
3566
3567 insert_node(&conn, "src", &empty_doc()).unwrap();
3569 for sql in [
3570 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3572 VALUES ('src', 'id', NULL, NULL)",
3573 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3575 VALUES ('src', 'id', 'src', 'foo')",
3576 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3578 VALUES ('src', 'name', NULL, NULL)",
3579 "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3581 VALUES ('src', 'other', 'src', NULL)",
3582 ] {
3583 assert!(
3584 conn.execute(sql, []).is_err(),
3585 "expected CHECK rejection: {sql}"
3586 );
3587 }
3588
3589 insert_node(&conn, "named", &named_doc("alpha", "x")).unwrap();
3591 let mut doc_with_both = referencing_doc(&["alpha"]);
3592 doc_with_both.blocks.push(Block::Paragraph {
3593 inlines: vec![Inline::Link {
3594 target: "id:src".into(),
3595 description: None,
3596 }],
3597 });
3598 insert_node(&conn, "mixed", &doc_with_both).unwrap();
3599 let rows: Vec<(String, String, Option<String>, Option<String>)> = conn
3600 .prepare(
3601 "SELECT source_id, link_type, target_id, target_slug FROM links \
3602 WHERE source_id = 'mixed' ORDER BY link_type",
3603 )
3604 .unwrap()
3605 .query_map([], |r| {
3606 Ok((
3607 r.get::<_, String>(0)?,
3608 r.get::<_, String>(1)?,
3609 r.get::<_, Option<String>>(2)?,
3610 r.get::<_, Option<String>>(3)?,
3611 ))
3612 })
3613 .unwrap()
3614 .collect::<Result<_, _>>()
3615 .unwrap();
3616 assert_eq!(
3617 rows,
3618 vec![
3619 ("mixed".into(), "id".into(), Some("src".into()), None),
3620 (
3621 "mixed".into(),
3622 "name".into(),
3623 Some("named".into()),
3624 Some("alpha".into())
3625 ),
3626 ]
3627 );
3628 }
3629
3630 #[test]
3632 fn id_link_semantics_preserved() {
3633 let doc = link_doc(&["aaa", "bbb", "aaa"]);
3635 let ids: Vec<String> = extract_links(&doc)
3636 .into_iter()
3637 .filter_map(|l| match l {
3638 LinkRef::Id(s) => Some(s),
3639 LinkRef::Name(_) => None,
3640 })
3641 .collect();
3642 assert_eq!(ids, vec!["aaa".to_string(), "bbb".to_string()]);
3643
3644 let conn = setup();
3646 insert_node(&conn, "tgt", &empty_doc()).unwrap();
3647 insert_node(&conn, "src", &link_doc(&["tgt"])).unwrap();
3648 let row: (Option<String>, String, Option<String>) = conn
3649 .query_row(
3650 "SELECT target_id, link_type, target_slug FROM links WHERE source_id = 'src'",
3651 [],
3652 |r| {
3653 Ok((
3654 r.get::<_, Option<String>>(0)?,
3655 r.get::<_, String>(1)?,
3656 r.get::<_, Option<String>>(2)?,
3657 ))
3658 },
3659 )
3660 .unwrap();
3661 assert_eq!(row, (Some("tgt".into()), "id".into(), None));
3662
3663 insert_node(&conn, "fwd", &link_doc(&["missing-tgt"])).unwrap();
3665 let n: i64 = conn
3666 .query_row(
3667 "SELECT COUNT(*) FROM links WHERE source_id = 'fwd'",
3668 [],
3669 |r| r.get(0),
3670 )
3671 .unwrap();
3672 assert_eq!(n, 0);
3673
3674 let n = get_neighborhood(&conn, "src").unwrap();
3676 assert_eq!(n.outgoing, vec![(NodeId("tgt".into()), LinkType::Id)]);
3677 let n = get_neighborhood(&conn, "tgt").unwrap();
3678 assert_eq!(n.incoming, vec![(NodeId("src".into()), LinkType::Id)]);
3679
3680 insert_node(&conn, "tgt2", &empty_doc()).unwrap();
3682 update_node(&conn, "src", &link_doc(&["tgt2"])).unwrap();
3683 let n = get_neighborhood(&conn, "src").unwrap();
3684 assert_eq!(n.outgoing, vec![(NodeId("tgt2".into()), LinkType::Id)]);
3685
3686 insert_node(&conn, "future-src", &link_doc(&["future-tgt"])).unwrap();
3688 insert_node(&conn, "future-tgt", &empty_doc()).unwrap();
3689 let (_, links) = relink_all(&conn).unwrap();
3690 assert!(links >= 1);
3691 let row: Option<String> = conn
3692 .query_row(
3693 "SELECT target_id FROM links \
3694 WHERE source_id = 'future-src' AND link_type = 'id'",
3695 [],
3696 |r| r.get(0),
3697 )
3698 .optional()
3699 .unwrap()
3700 .flatten();
3701 assert_eq!(row.as_deref(), Some("future-tgt"));
3702
3703 delete_node(&conn, "future-tgt").unwrap();
3706 let n: i64 = conn
3707 .query_row(
3708 "SELECT COUNT(*) FROM links \
3709 WHERE source_id = 'future-src' AND link_type = 'id'",
3710 [],
3711 |r| r.get(0),
3712 )
3713 .unwrap();
3714 assert_eq!(n, 0);
3715 }
3716
3717 #[test]
3720 fn name_link_broken_and_reverse_resolve_preserved() {
3721 let conn = setup();
3722
3723 insert_node(&conn, "src1", &referencing_doc(&["future"])).unwrap();
3725 let row = fetch_name_link(&conn, "src1", "future").unwrap();
3726 assert_eq!(row.link_type, LinkType::Name);
3727 assert_eq!(row.target_slug.as_deref(), Some("future"));
3728 assert!(row.target_id.is_none());
3729
3730 insert_node(&conn, "named", &named_doc("alpha", "I am alpha")).unwrap();
3732 insert_node(&conn, "src2", &referencing_doc(&["alpha"])).unwrap();
3733 let row = fetch_name_link(&conn, "src2", "alpha").unwrap();
3734 assert_eq!(row.target_id.as_deref(), Some("named"));
3735
3736 insert_node(&conn, "src3", &referencing_doc(&["beta"])).unwrap();
3738 assert!(
3739 fetch_name_link(&conn, "src3", "beta")
3740 .unwrap()
3741 .target_id
3742 .is_none()
3743 );
3744 insert_node(&conn, "beta-target", &named_doc("beta", "x")).unwrap();
3745 let row = fetch_name_link(&conn, "src3", "beta").unwrap();
3746 assert_eq!(row.target_id.as_deref(), Some("beta-target"));
3747
3748 insert_node(&conn, "src4", &referencing_doc(&["gamma"])).unwrap();
3750 insert_node(
3751 &conn,
3752 "gamma-target",
3753 &Document {
3754 blocks: vec![Block::Paragraph {
3755 inlines: vec![Inline::Plain("unnamed".into())],
3756 }],
3757 },
3758 )
3759 .unwrap();
3760 assert!(
3761 fetch_name_link(&conn, "src4", "gamma")
3762 .unwrap()
3763 .target_id
3764 .is_none()
3765 );
3766 update_node(&conn, "gamma-target", &named_doc("gamma", "x")).unwrap();
3767 let row = fetch_name_link(&conn, "src4", "gamma").unwrap();
3768 assert_eq!(row.target_id.as_deref(), Some("gamma-target"));
3769
3770 update_node(&conn, "src2", &referencing_doc(&["beta"])).unwrap();
3772 assert!(fetch_name_link(&conn, "src2", "alpha").is_none());
3773 let row = fetch_name_link(&conn, "src2", "beta").unwrap();
3774 assert_eq!(row.target_id.as_deref(), Some("beta-target"));
3775
3776 insert_node(&conn, "src5", &referencing_doc(&["missing-1", "missing-2"])).unwrap();
3778 let broken = list_broken_links(&conn).unwrap();
3779 let slugs: Vec<&str> = broken
3780 .iter()
3781 .map(|r| r.target_slug.as_deref().unwrap_or(""))
3782 .collect();
3783 assert!(slugs.contains(&"missing-1"));
3784 assert!(slugs.contains(&"missing-2"));
3785 for row in &broken {
3786 assert_eq!(row.link_type, LinkType::Name);
3787 assert!(row.target_id.is_none());
3788 }
3789 }
3790
3791 #[test]
3794 fn name_link_demote_preserved() {
3795 let conn = setup();
3796 insert_node(&conn, "target", &named_doc("alpha", "x")).unwrap();
3797 insert_node(&conn, "source", &referencing_doc(&["alpha"])).unwrap();
3798 assert_eq!(
3799 fetch_name_link(&conn, "source", "alpha")
3800 .unwrap()
3801 .target_id
3802 .as_deref(),
3803 Some("target")
3804 );
3805
3806 update_node(&conn, "target", &named_doc("renamed", "x")).unwrap();
3808 let row = fetch_name_link(&conn, "source", "alpha").unwrap();
3809 assert!(row.target_id.is_none(), "slug change must demote to broken");
3810 assert_eq!(row.target_slug.as_deref(), Some("alpha"));
3811
3812 update_node(&conn, "target", &named_doc("alpha", "x")).unwrap();
3815 assert_eq!(
3816 fetch_name_link(&conn, "source", "alpha")
3817 .unwrap()
3818 .target_id
3819 .as_deref(),
3820 Some("target")
3821 );
3822 delete_node(&conn, "target").unwrap();
3823 let row = fetch_name_link(&conn, "source", "alpha").unwrap();
3824 assert!(row.target_id.is_none(), "delete must demote to broken");
3825 assert_eq!(row.target_slug.as_deref(), Some("alpha"));
3826
3827 delete_node(&conn, "source").unwrap();
3829 assert_eq!(count_links_total(&conn), 0);
3830 }
3831
3832 #[test]
3834 fn migration_from_split_tables() {
3835 let conn = Connection::open_in_memory().unwrap();
3839 conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
3840 conn.execute(NODES_TABLE, []).unwrap();
3841 conn.execute(NODE_TAGS_TABLE, []).unwrap();
3842 conn.execute(
3844 "CREATE TABLE links ( \
3845 source_id TEXT NOT NULL, \
3846 target_id TEXT NOT NULL, \
3847 link_type TEXT NOT NULL, \
3848 PRIMARY KEY (source_id, target_id, link_type), \
3849 FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE, \
3850 FOREIGN KEY (target_id) REFERENCES nodes(id) ON DELETE CASCADE)",
3851 [],
3852 )
3853 .unwrap();
3854 conn.execute(
3855 "CREATE TABLE name_links ( \
3856 src_id TEXT NOT NULL, \
3857 dst_slug TEXT NOT NULL, \
3858 dst_id TEXT, \
3859 PRIMARY KEY (src_id, dst_slug), \
3860 FOREIGN KEY (src_id) REFERENCES nodes(id) ON DELETE CASCADE)",
3861 [],
3862 )
3863 .unwrap();
3864 for nid in ["a", "b", "c", "d"] {
3866 conn.execute(
3867 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) \
3868 VALUES (?1, 't', ?2, '0', '0')",
3869 params![nid, sexp::encode_document(&Document { blocks: vec![] })],
3870 )
3871 .unwrap();
3872 }
3873 conn.execute(
3875 "INSERT INTO links (source_id, target_id, link_type) VALUES ('a', 'b', 'id')",
3876 [],
3877 )
3878 .unwrap();
3879 conn.execute(
3881 "INSERT INTO name_links (src_id, dst_slug, dst_id) VALUES ('c', 'beta', 'b')",
3882 [],
3883 )
3884 .unwrap();
3885 conn.execute(
3887 "INSERT INTO name_links (src_id, dst_slug, dst_id) VALUES ('d', 'ghost', NULL)",
3888 [],
3889 )
3890 .unwrap();
3891
3892 init_db(&conn).unwrap();
3894
3895 let n_name_links: i64 = conn
3897 .query_row(
3898 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
3899 [],
3900 |r| r.get(0),
3901 )
3902 .unwrap();
3903 assert_eq!(n_name_links, 0);
3904
3905 let mut rows: Vec<(String, String, Option<String>, Option<String>)> = conn
3907 .prepare(
3908 "SELECT source_id, link_type, target_id, target_slug FROM links \
3909 ORDER BY source_id",
3910 )
3911 .unwrap()
3912 .query_map([], |r| {
3913 Ok((
3914 r.get::<_, String>(0)?,
3915 r.get::<_, String>(1)?,
3916 r.get::<_, Option<String>>(2)?,
3917 r.get::<_, Option<String>>(3)?,
3918 ))
3919 })
3920 .unwrap()
3921 .collect::<Result<_, _>>()
3922 .unwrap();
3923 rows.sort();
3924 assert_eq!(
3925 rows,
3926 vec![
3927 ("a".into(), "id".into(), Some("b".into()), None),
3928 (
3929 "c".into(),
3930 "name".into(),
3931 Some("b".into()),
3932 Some("beta".into())
3933 ),
3934 ("d".into(), "name".into(), None, Some("ghost".into())),
3935 ]
3936 );
3937
3938 init_db(&conn).unwrap();
3940 let n: i64 = count_links_total(&conn);
3941 assert_eq!(n, 3);
3942 }
3943
3944 #[test]
3945 fn link_parser_extracts_bracket_name_refs() {
3946 let doc = Document {
3947 blocks: vec![Block::Paragraph {
3948 inlines: vec![
3949 Inline::Plain("see ".into()),
3950 Inline::Link {
3951 target: "alpha".into(),
3952 description: None,
3953 },
3954 Inline::Plain(" and ".into()),
3955 Inline::Link {
3956 target: "beta".into(),
3957 description: None,
3958 },
3959 ],
3960 }],
3961 };
3962 let slugs: Vec<String> = extract_links(&doc)
3963 .into_iter()
3964 .filter_map(|l| match l {
3965 LinkRef::Name(s) => Some(s),
3966 LinkRef::Id(_) => None,
3967 })
3968 .collect();
3969 assert_eq!(slugs, vec!["alpha".to_string(), "beta".to_string()]);
3970 }
3971
3972 #[test]
3973 fn link_parser_extracts_dedupes_preserving_first_seen_order() {
3974 let doc = referencing_doc(&["alpha", "beta", "alpha", "gamma"]);
3975 let slugs: Vec<String> = extract_links(&doc)
3976 .into_iter()
3977 .filter_map(|l| match l {
3978 LinkRef::Name(s) => Some(s),
3979 LinkRef::Id(_) => None,
3980 })
3981 .collect();
3982 assert_eq!(slugs, vec!["alpha", "beta", "gamma"]);
3983 }
3984
3985 #[test]
3986 fn link_parser_extracts_walks_nested_inline_positions() {
3987 let doc = Document {
3988 blocks: vec![Block::Paragraph {
3989 inlines: vec![Inline::Bold(vec![Inline::Italic(vec![Inline::Link {
3990 target: "nested".into(),
3991 description: None,
3992 }])])],
3993 }],
3994 };
3995 let slugs: Vec<String> = extract_links(&doc)
3996 .into_iter()
3997 .filter_map(|l| match l {
3998 LinkRef::Name(s) => Some(s),
3999 LinkRef::Id(_) => None,
4000 })
4001 .collect();
4002 assert_eq!(slugs, vec!["nested".to_string()]);
4003 }
4004
4005 #[test]
4006 fn link_parser_extracts_ignores_scheme_links() {
4007 let doc = Document {
4008 blocks: vec![Block::Paragraph {
4009 inlines: vec![
4010 Inline::Link {
4011 target: "id:abc-123".into(),
4012 description: None,
4013 },
4014 Inline::Link {
4015 target: "https://example.com".into(),
4016 description: Some("ex".into()),
4017 },
4018 Inline::Link {
4019 target: "keep".into(),
4020 description: None,
4021 },
4022 Inline::Link {
4023 target: "alpha".into(),
4024 description: Some("Alpha".into()),
4025 },
4026 ],
4027 }],
4028 };
4029 let slugs: Vec<String> = extract_links(&doc)
4030 .into_iter()
4031 .filter_map(|l| match l {
4032 LinkRef::Name(s) => Some(s),
4033 LinkRef::Id(_) => None,
4034 })
4035 .collect();
4036 assert_eq!(slugs, vec!["keep".to_string()]);
4037 }
4038
4039 #[test]
4040 fn parser_respects_literal_regions_for_bracket_refs() {
4041 let doc = Document {
4042 blocks: vec![
4043 Block::Paragraph {
4044 inlines: vec![
4045 Inline::Plain("Use ".into()),
4046 Inline::Verbatim("[[verbatim-target]]".into()),
4047 Inline::Plain(" for verbatim, ".into()),
4048 Inline::InlineCode("[[code-target]]".into()),
4049 Inline::Plain(" for inline code.".into()),
4050 ],
4051 },
4052 Block::SrcBlock {
4053 language: "org".into(),
4054 content: "Example: [[src-block-target]]\n".into(),
4055 },
4056 Block::ExampleBlock {
4057 content: "Example: [[example-block-target]]\n".into(),
4058 },
4059 Block::Paragraph {
4060 inlines: vec![Inline::Link {
4061 target: "real-target".into(),
4062 description: None,
4063 }],
4064 },
4065 ],
4066 };
4067 let slugs: Vec<String> = extract_links(&doc)
4068 .into_iter()
4069 .filter_map(|l| match l {
4070 LinkRef::Name(s) => Some(s),
4071 LinkRef::Id(_) => None,
4072 })
4073 .collect();
4074 assert_eq!(slugs, vec!["real-target".to_string()]);
4075 }
4076
4077 #[test]
4078 fn parser_respects_literal_regions_round_trip_via_org_parser() {
4079 let org = "Use =[[verbatim-target]]= for verbatim, \
4080 ~[[code-target]]~ for inline code.\n\
4081 #+begin_src org\n\
4082 Example: [[src-block-target]]\n\
4083 #+end_src\n\
4084 #+begin_example\n\
4085 Example: [[example-block-target]]\n\
4086 #+end_example\n\
4087 [[real-target]]\n";
4088 let doc = crate::parser::parse_document(org).unwrap();
4089 let slugs: Vec<String> = extract_links(&doc)
4090 .into_iter()
4091 .filter_map(|l| match l {
4092 LinkRef::Name(s) => Some(s),
4093 LinkRef::Id(_) => None,
4094 })
4095 .collect();
4096 assert_eq!(slugs, vec!["real-target".to_string()]);
4097 }
4098
4099 #[test]
4101 fn unified_walker_emits_both_link_types_in_one_pass() {
4102 let doc = Document {
4103 blocks: vec![Block::Paragraph {
4104 inlines: vec![
4105 Inline::Link {
4106 target: "id:abc".into(),
4107 description: None,
4108 },
4109 Inline::Link {
4110 target: "alpha".into(),
4111 description: None,
4112 },
4113 Inline::Link {
4114 target: "id:def".into(),
4115 description: None,
4116 },
4117 Inline::Link {
4118 target: "beta".into(),
4119 description: None,
4120 },
4121 ],
4122 }],
4123 };
4124 let refs = extract_links(&doc);
4125 assert_eq!(
4126 refs,
4127 vec![
4128 LinkRef::Id("abc".into()),
4129 LinkRef::Name("alpha".into()),
4130 LinkRef::Id("def".into()),
4131 LinkRef::Name("beta".into()),
4132 ]
4133 );
4134 }
4135
4136 #[test]
4137 fn list_orphans_counts_across_both_link_types() {
4138 let conn = setup();
4139 insert_node(&conn, "hub", &named_doc("hub", "x")).unwrap();
4141 insert_node(&conn, "src-name", &referencing_doc(&["hub"])).unwrap();
4142 insert_node(&conn, "id-hub", &empty_doc()).unwrap();
4144 insert_node(&conn, "src-id", &link_doc(&["id-hub"])).unwrap();
4145 insert_node(
4147 &conn,
4148 "lonely",
4149 &Document {
4150 blocks: vec![Block::Paragraph {
4151 inlines: vec![Inline::Plain("no one links to me".into())],
4152 }],
4153 },
4154 )
4155 .unwrap();
4156 let orphans = list_orphans(&conn).unwrap();
4157 let ids: Vec<&str> = orphans.iter().map(|r| r.id.as_str()).collect();
4158 assert!(!ids.contains(&"hub"));
4161 assert!(!ids.contains(&"id-hub"));
4162 assert!(ids.contains(&"src-name"));
4163 assert!(ids.contains(&"src-id"));
4164 assert!(ids.contains(&"lonely"));
4165 }
4166
4167 #[test]
4168 fn list_hubs_ranks_by_total_in_degree_across_link_types() {
4169 let conn = setup();
4170 insert_node(&conn, "hot", &named_doc("hot", "x")).unwrap();
4171 insert_node(&conn, "warm", &empty_doc()).unwrap();
4172 insert_node(&conn, "a", &referencing_doc(&["hot"])).unwrap();
4174 insert_node(&conn, "b", &referencing_doc(&["hot"])).unwrap();
4175 insert_node(&conn, "c", &link_doc(&["hot"])).unwrap();
4177 insert_node(&conn, "d", &link_doc(&["warm"])).unwrap();
4179 let hubs = list_hubs(&conn, 10).unwrap();
4180 assert_eq!(hubs[0].id, "hot");
4181 assert_eq!(hubs[0].in_degree, 3);
4182 assert_eq!(hubs[1].id, "warm");
4183 assert_eq!(hubs[1].in_degree, 1);
4184 }
4185
4186 #[test]
4187 fn list_hubs_zero_limit_returns_empty() {
4188 let conn = setup();
4189 insert_node(&conn, "hot", &named_doc("hot", "x")).unwrap();
4190 insert_node(&conn, "a", &referencing_doc(&["hot"])).unwrap();
4191 assert!(list_hubs(&conn, 0).unwrap().is_empty());
4192 }
4193
4194 #[test]
4195 fn list_broken_links_returns_only_unresolved_name_rows() {
4196 let conn = setup();
4197 insert_node(&conn, "named", &named_doc("alpha", "x")).unwrap();
4198 insert_node(&conn, "src1", &referencing_doc(&["alpha", "missing"])).unwrap();
4199 insert_node(&conn, "src2", &referencing_doc(&["also-missing"])).unwrap();
4200 let broken = list_broken_links(&conn).unwrap();
4201 let slugs: Vec<&str> = broken
4202 .iter()
4203 .map(|r| r.target_slug.as_deref().unwrap_or(""))
4204 .collect();
4205 assert_eq!(slugs, vec!["also-missing", "missing"]);
4206 for row in &broken {
4207 assert_eq!(row.link_type, LinkType::Name);
4208 assert!(row.target_id.is_none());
4209 }
4210 }
4211
4212 #[test]
4213 fn get_links_returns_full_outgoing_including_broken() {
4214 let conn = setup();
4215 insert_node(&conn, "hub", &named_doc("hub", "x")).unwrap();
4216 insert_node(&conn, "src", &referencing_doc(&["hub", "ghost"])).unwrap();
4217 let nb = get_links(&conn, "src").unwrap();
4218 assert_eq!(nb.outgoing.len(), 2);
4220 let resolved = nb
4221 .outgoing
4222 .iter()
4223 .find(|r| r.target_slug.as_deref() == Some("hub"))
4224 .unwrap();
4225 assert_eq!(resolved.target_id.as_deref(), Some("hub"));
4226 let broken = nb
4227 .outgoing
4228 .iter()
4229 .find(|r| r.target_slug.as_deref() == Some("ghost"))
4230 .unwrap();
4231 assert!(broken.target_id.is_none());
4232 }
4233
4234 #[test]
4235 fn relink_all_skips_corrupt_blob_in_unified_path() {
4236 let conn = setup();
4237 insert_node(&conn, "good", &named_doc("g", "x")).unwrap();
4238 conn.execute(
4239 "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) \
4240 VALUES ('bad', 'x', 'not-a-sexp', '0', '0')",
4241 [],
4242 )
4243 .unwrap();
4244 let (nodes, _) = relink_all(&conn).unwrap();
4245 assert_eq!(nodes, 2);
4246 }
4247}