Skip to main content

kb/
storage.rs

1//! SQLite-backed persistence for kb.
2//!
3//! The schema mirrors the Haskell `KB.Storage` bootstrap. Connection
4//! pragmas run on every connection; bootstrap DDL initializes a fresh
5//! database. AST blobs use s-expression encoding via [`crate::sexp`]
6//! matching the Haskell on-disk format.
7//!
8//! ## Design decisions
9//!
10//! [AST encoding] `nodes.ast_blob` stores the AST as an s-expression via
11//! `crate::sexp`. Versioned with a `(kb-doc 1 …)` wrapper.
12//!
13//! [Tags] Tags are normalized into `node_tags` (one row per (node, tag)).
14//!
15//! [Links] One physical `links` table holds both id-links and
16//! name-links, discriminated by `link_type` ('id' | 'name'). `source_id`
17//! is a FOREIGN KEY into `nodes(id)` ON DELETE CASCADE. `target_id` is
18//! a FOREIGN KEY into `nodes(id)` ON DELETE SET NULL — name-link rows
19//! demote to broken (target_id NULL) when their target is removed; the
20//! `delete_node` codepath manually removes id-link rows pointing at the
21//! deleted node before the cascade so the CHECK constraint (id-link
22//! rows require NOT NULL target_id) is never violated. A schema-level
23//! CHECK enforces the per-link_type column contract:
24//!   id  -> target_id NOT NULL, target_slug NULL
25//!   name -> target_slug NOT NULL (target_id may be NULL = broken)
26//!
27//! [Audit log] `audit_log.node_id` is plain `TEXT` with no foreign key,
28//! so a deleted node's history survives. Every mutation
29//! (`insert_node`/`update_node`/`delete_node`) writes one audit row in
30//! the same transaction as the data change.
31//!
32//! [Schema version] Tracked via `PRAGMA user_version`. Bootstrap stamps
33//! it to [`CURRENT_SCHEMA_VERSION`].
34//!
35//! [Title/tag derivation] `insert_node` / `update_node` take a `Document`
36//! and derive the row's title and tags from its content. Title comes from
37//! the first heading, or first paragraph text truncated to 80 chars,
38//! or "(untitled)".
39
40use crate::error::KbError;
41use std::cmp::Ordering;
42use std::collections::{BTreeMap, HashSet};
43
44use rusqlite::{Connection, OptionalExtension, params};
45
46use crate::ast::*;
47use crate::embedding;
48use crate::sexp;
49
50// ── Schema ──────────────────────────────────────────────────────────────
51
52/// Schema version stamped into `PRAGMA user_version` at bootstrap.
53///
54/// v1 mirrors Haskell `KB.Storage.currentSchemaVersion`. v2 re-encodes
55/// every `ast_blob` to the current sexp format (ordered lists carry a
56/// start number; legacy blobs used a bare `ordered` symbol).
57pub 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
78// Unified link graph. One physical table; `link_type` discriminates.
79//
80// Column contract (enforced by CHECK):
81//   link_type='id'   -> target_id NOT NULL, target_slug NULL
82//   link_type='name' -> target_slug NOT NULL (target_id may be NULL)
83//
84// `source_id` FK ON DELETE CASCADE: a node's outgoing rows go with it.
85// `target_id` FK ON DELETE SET NULL: name-link rows demote to broken
86// (target_id NULL, target_slug intact) when the target node is removed.
87// `delete_node` removes id-link rows pointing at the deleted node
88// explicitly before the cascade so the CHECK is never violated.
89//
90// The PK column order puts `link_type` second so a per-source lookup
91// (the relinker's hot path) is index-served, and so id vs name rows
92// for the same source are stored contiguously.
93const 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
155/// Open a database connection with pragmas applied.
156///
157/// # Errors
158///
159/// Returns `rusqlite::Error` on connection failure.
160pub fn open_db(path: &str) -> Result<Connection, KbError> {
161    // Best-effort: ensure the parent directory exists so the default
162    // `$HOME/.local/share/kb/` location works on first run. A genuine
163    // failure surfaces from `Connection::open` below.
164    if let Some(parent) = std::path::Path::new(path).parent() {
165        if !parent.as_os_str().is_empty() {
166            let _ = std::fs::create_dir_all(parent);
167        }
168    }
169    let conn = Connection::open(path)?;
170    conn.execute_batch("PRAGMA foreign_keys = ON;")?;
171    Ok(conn)
172}
173
174/// Default kb database path: `$HOME/.local/share/kb/kb.db`.
175///
176/// Falls back to `kb.db` in the current directory when `$HOME` is unset.
177#[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
185/// Bootstrap the database schema. Idempotent (uses IF NOT EXISTS).
186///
187/// Creates every table / index in the schema, runs version-gated data
188/// migrations for databases below [`CURRENT_SCHEMA_VERSION`], and stamps
189/// `PRAGMA user_version = CURRENT_SCHEMA_VERSION`.
190///
191/// # Errors
192///
193/// Returns [`KbError::Database`] if schema creation fails, or
194/// [`KbError::CorruptAstBlob`] if a stored blob fails to decode during
195/// the v2 re-encode migration.
196pub 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
219/// Re-encode every `ast_blob` to the current sexp format (schema v2).
220///
221/// v1 blobs encoded ordered lists as a bare `ordered` symbol; the current
222/// format is `(ordered N)`. The decoder accepts both, so this pass is
223/// decode → encode → write-back-if-changed. Node timestamps, the audit
224/// log, and the FTS index are untouched: the document content is
225/// identical, only its serialization changes.
226fn 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
248/// Create the unified `links` table, migrating from the pre-consolidation
249/// split shape if it is present.
250///
251/// Pre-consolidation databases (kb-link-index-v0) carried two physical
252/// tables:
253///   - `links(source_id, target_id, link_type='id')` — UUID id-links, FK
254///     CASCADE on both columns
255///   - `name_links(src_id, dst_slug, dst_id NULLABLE)` — slug name-links
256///
257/// The migration is idempotent and runs in one transaction:
258///   1. Detect a `name_links` table or a pre-consolidation `links` shape
259///      (no `target_slug` column).
260///   2. If found, snapshot the rows, drop both legacy tables, recreate
261///      the unified `links` table, and re-insert every row preserving
262///      `target_id` (including NULL for broken name-links) and
263///      `target_slug`. Id-link rows write `target_slug = NULL`.
264///   3. If absent, just `CREATE TABLE IF NOT EXISTS links` — the unified
265///      shape — so a fresh database lands at the new schema directly.
266fn 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        // Fresh DB or already at the unified shape.
273        conn.execute(LINKS_TABLE, [])?;
274        return Ok(());
275    }
276
277    // Snapshot rows from whichever legacy tables are present.
278    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// ── Row types ───────────────────────────────────────────────────────────
350
351/// A row from the `nodes` table.
352#[derive(Debug, Clone)]
353pub struct NodeRow {
354    pub id: NodeId,
355    pub title: Title,
356    pub ast_blob: String,
357    pub created_at: String,
358    pub updated_at: String,
359}
360
361/// A row from the unified `links` table.
362///
363/// `target_id` is `Some` for every resolved row (id-links by
364/// construction, name-links once their slug is matched);
365/// `target_slug` is `Some` for every name-link row and `None` for
366/// id-link rows.
367/// Discriminator for a row in the unified `links` table: an id-link
368/// (UUID `target_id`) or a name-link (bracketed `target_slug`).
369#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
370#[serde(rename_all = "lowercase")]
371pub enum LinkType {
372    /// A link to a node by its UUID (`target_id`).
373    Id,
374    /// A link to a node by bracketed slug (`target_slug`).
375    Name,
376}
377
378impl LinkType {
379    /// The on-disk / wire token for this link type (`"id"` or `"name"`).
380    #[must_use]
381    pub const fn as_str(self) -> &'static str {
382        match self {
383            Self::Id => "id",
384            Self::Name => "name",
385        }
386    }
387}
388
389impl std::fmt::Display for LinkType {
390    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391        f.write_str(self.as_str())
392    }
393}
394
395impl rusqlite::types::FromSql for LinkType {
396    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
397        match value.as_str()? {
398            "id" => Ok(Self::Id),
399            "name" => Ok(Self::Name),
400            // The schema CHECK restricts this column to 'id' | 'name', so any
401            // other value can only come from external corruption.
402            _ => Err(rusqlite::types::FromSqlError::InvalidType),
403        }
404    }
405}
406
407impl rusqlite::types::FromSql for NodeId {
408    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
409        value.as_str().map(|s| Self(s.to_owned()))
410    }
411}
412
413impl rusqlite::types::FromSql for Title {
414    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
415        value.as_str().map(|s| Self(s.to_owned()))
416    }
417}
418
419impl rusqlite::types::FromSql for Tag {
420    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
421        value.as_str().map(|s| Self(s.to_owned()))
422    }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct LinkRow {
427    pub source_id: String,
428    pub link_type: LinkType,
429    pub target_id: Option<String>,
430    pub target_slug: Option<String>,
431}
432
433/// A row from the `audit_log` table.
434#[derive(Debug, Clone)]
435pub struct AuditRow {
436    pub id: i64,
437    pub node_id: String,
438    pub operation: String,
439    pub old_blob: Option<String>,
440    pub new_blob: Option<String>,
441    pub timestamp: String,
442}
443
444/// Graph neighborhood of a node: outgoing and incoming edges as
445/// `(other-node-id, link_type)` pairs. Order is unspecified.
446#[derive(Debug, Clone, Default, PartialEq, Eq)]
447pub struct Neighborhood {
448    pub outgoing: Vec<(NodeId, LinkType)>,
449    pub incoming: Vec<(NodeId, LinkType)>,
450}
451
452fn now_iso8601() -> String {
453    chrono::Utc::now()
454        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
455        .to_string()
456}
457
458// ── Embedding write helper ──────────────────────────────────────────────
459
460/// Write a precomputed embedding row keyed by `(node_id, model)`. No-op
461/// when `embedding` or `model` is `None`. Logs and continues on a SQL
462/// failure — storage layer is otherwise ignorant of embedding HTTP.
463fn write_embedding_row(
464    conn: &Connection,
465    node_id: &str,
466    embedding: Option<Vec<f32>>,
467    model: Option<&str>,
468) {
469    let (Some(vec), Some(model)) = (embedding, model) else {
470        return;
471    };
472    let blob = embedding::encode_embedding(&vec);
473    if let Err(e) = conn.execute(
474        "INSERT OR REPLACE INTO embeddings (node_id, model, embedding) VALUES (?1, ?2, ?3)",
475        params![node_id, model, blob],
476    ) {
477        tracing::error!(node_id, error = %e, "kb embed write failed");
478    }
479}
480
481// ── Operations ──────────────────────────────────────────────────────────
482
483/// Insert a new node. Title and tags are derived from the AST. The full
484/// insert (nodes row, node_tags rows, FTS5 row, audit_log row, outgoing
485/// links) is wrapped in a single transaction.
486///
487/// # Errors
488///
489/// Returns `rusqlite::Error` on database failure.
490pub fn insert_node(
491    conn: &Connection,
492    node_id: &str,
493    document: &Document,
494) -> Result<(), rusqlite::Error> {
495    insert_node_with(conn, node_id, document, None, None)
496}
497
498/// [`insert_node`] with a precomputed embedding.
499///
500/// The caller (axum or MCP handler) computes the embedding via the
501/// async [`crate::embedding::EmbeddingClient`] before invoking storage.
502/// The node write commits first; the precomputed embedding row, if any,
503/// is then written in a separate statement so a SQL failure on the
504/// embeddings table does not roll back the data write.
505///
506/// When `embedding` is `Some(v)` and `model` is `Some(m)`, one row is
507/// upserted into the `embeddings` table keyed by `(node_id, m)`.
508/// Otherwise no embedding row is written.
509///
510/// # Errors
511///
512/// Returns `rusqlite::Error` on database failure of the node write.
513/// Embedding-write failures are logged and ignored.
514pub fn insert_node_with(
515    conn: &Connection,
516    node_id: &str,
517    document: &Document,
518    embedding: Option<Vec<f32>>,
519    model: Option<&str>,
520) -> Result<(), rusqlite::Error> {
521    let blob = sexp::encode_document(document);
522    let title = extract_title(document);
523    let tags = extract_tags(document);
524    let body_text = extract_body_text(document);
525    let now = now_iso8601();
526
527    let tx = conn.unchecked_transaction()?;
528
529    tx.execute(
530        "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
531        params![node_id, title, blob, now, now],
532    )?;
533    for tag in &tags {
534        tx.execute(
535            "INSERT INTO node_tags (node_id, tag) VALUES (?1, ?2)",
536            params![node_id, tag.0.as_str()],
537        )?;
538    }
539    tx.execute(
540        "INSERT INTO nodes_fts (node_id, title, body) VALUES (?1, ?2, ?3)",
541        params![node_id, title, body_text],
542    )?;
543    tx.execute(
544        "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'insert', NULL, ?2, ?3)",
545        params![node_id, blob, now],
546    )?;
547
548    relink_one_inner(&tx, node_id, document)?;
549    // If this new node carries a `#+name:` slug, back-resolve any
550    // previously-broken bracket references pointing at it.
551    if let Some(slug) = extract_name_slug(document) {
552        tx.execute(
553            "UPDATE links SET target_id = ?1 \
554             WHERE link_type = 'name' AND target_slug = ?2 AND target_id IS NULL",
555            params![node_id, slug],
556        )?;
557    }
558
559    tx.commit()?;
560
561    write_embedding_row(conn, node_id, embedding, model);
562
563    Ok(())
564}
565
566/// Get a node by ID. Decodes the sexp blob into a [`Document`].
567///
568/// # Errors
569///
570/// Returns [`KbError::Database`] on database failure, or
571/// [`KbError::CorruptAstBlob`] when the stored blob fails to decode.
572pub fn get_node(conn: &Connection, id: &str) -> Result<Option<Document>, KbError> {
573    let mut stmt = conn.prepare("SELECT ast_blob FROM nodes WHERE id = ?1")?;
574    let mut rows = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
575    match rows.next() {
576        Some(Ok(blob)) => {
577            let doc = decode_blob(id, &blob)?;
578            Ok(Some(doc))
579        }
580        Some(Err(e)) => Err(KbError::Database(e)),
581        None => Ok(None),
582    }
583}
584
585/// Decode a stored `ast_blob`, attributing failure to the owning node.
586fn decode_blob(node_id: &str, blob: &str) -> Result<Document, KbError> {
587    sexp::decode_document(blob).map_err(|e| KbError::CorruptAstBlob {
588        node_id: node_id.to_string(),
589        reason: e.to_string(),
590    })
591}
592
593/// Get a raw node row (without decoding the blob).
594///
595/// # Errors
596///
597/// Returns `rusqlite::Error` on database failure.
598pub fn get_node_row(conn: &Connection, id: &str) -> Result<Option<NodeRow>, KbError> {
599    let mut stmt = conn
600        .prepare("SELECT id, title, ast_blob, created_at, updated_at FROM nodes WHERE id = ?1")?;
601    let mut rows = stmt.query_map(params![id], |row| {
602        Ok(NodeRow {
603            id: row.get(0)?,
604            title: row.get(1)?,
605            ast_blob: row.get(2)?,
606            created_at: row.get(3)?,
607            updated_at: row.get(4)?,
608        })
609    })?;
610    match rows.next() {
611        Some(Ok(row)) => Ok(Some(row)),
612        Some(Err(e)) => Err(KbError::Database(e)),
613        None => Ok(None),
614    }
615}
616
617/// Update an existing node. Returns `false` if the id is unknown. The
618/// full update (row replacement, node_tags refresh, FTS5 refresh, audit
619/// row, outgoing links) is wrapped in a single transaction.
620///
621/// # Errors
622///
623/// Returns `rusqlite::Error` on database failure.
624pub fn update_node(
625    conn: &Connection,
626    id: &str,
627    document: &Document,
628) -> Result<bool, rusqlite::Error> {
629    update_node_with(conn, id, document, None, None)
630}
631
632/// [`update_node`] with a precomputed embedding.
633///
634/// The caller computes the embedding via the async
635/// [`crate::embedding::EmbeddingClient`] before invoking storage. The
636/// node write commits first; the precomputed embedding row, if any, is
637/// then upserted in a separate statement so a SQL failure on the
638/// embeddings table does not roll back the data write.
639///
640/// When `embedding` is `Some(v)` and `model` is `Some(m)`, one row is
641/// upserted into the `embeddings` table keyed by `(id, m)`. Otherwise no
642/// embedding row is written.
643///
644/// # Errors
645///
646/// Returns `rusqlite::Error` on database failure of the node write.
647/// Embedding-write failures are logged and ignored.
648pub fn update_node_with(
649    conn: &Connection,
650    id: &str,
651    document: &Document,
652    embedding: Option<Vec<f32>>,
653    model: Option<&str>,
654) -> Result<bool, rusqlite::Error> {
655    let tx = conn.unchecked_transaction()?;
656
657    let prior_blob: Option<String> = tx
658        .query_row(
659            "SELECT ast_blob FROM nodes WHERE id = ?1",
660            params![id],
661            |row| row.get(0),
662        )
663        .optional()?;
664    let Some(prior_blob) = prior_blob else {
665        return Ok(false);
666    };
667
668    let blob = sexp::encode_document(document);
669    let title = extract_title(document);
670    let tags = extract_tags(document);
671    let body_text = extract_body_text(document);
672    let now = now_iso8601();
673
674    tx.execute(
675        "UPDATE nodes SET title = ?1, ast_blob = ?2, updated_at = ?3 WHERE id = ?4",
676        params![title, blob, now, id],
677    )?;
678    tx.execute("DELETE FROM node_tags WHERE node_id = ?1", params![id])?;
679    for tag in &tags {
680        tx.execute(
681            "INSERT INTO node_tags (node_id, tag) VALUES (?1, ?2)",
682            params![id, tag.0.as_str()],
683        )?;
684    }
685    tx.execute("DELETE FROM nodes_fts WHERE node_id = ?1", params![id])?;
686    tx.execute(
687        "INSERT INTO nodes_fts (node_id, title, body) VALUES (?1, ?2, ?3)",
688        params![id, title, body_text],
689    )?;
690    tx.execute(
691        "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'update', ?2, ?3, ?4)",
692        params![id, prior_blob, blob, now],
693    )?;
694
695    // Decode prior blob to see whether the node previously carried a
696    // `#+name:` slug. If the slug changes (or disappears), invalidate
697    // any name-link rows that were resolved at this node so they go
698    // back to broken.
699    let prior_slug = sexp::decode_document(&prior_blob)
700        .ok()
701        .as_ref()
702        .and_then(extract_name_slug);
703    let new_slug = extract_name_slug(document);
704    if prior_slug.as_deref() != new_slug.as_deref() {
705        invalidate_resolved_pointing_at(&tx, id)?;
706    }
707    relink_one_inner(&tx, id, document)?;
708    // Back-resolve broken bracket references against this node's
709    // (possibly new) `#+name:` slug.
710    if let Some(slug) = new_slug {
711        tx.execute(
712            "UPDATE links SET target_id = ?1 \
713             WHERE link_type = 'name' AND target_slug = ?2 AND target_id IS NULL",
714            params![id, slug],
715        )?;
716    }
717
718    tx.commit()?;
719
720    write_embedding_row(conn, id, embedding, model);
721
722    Ok(true)
723}
724
725/// Hard-delete a node. Cascades to `node_tags` and `links`. Audit row is
726/// written **before** the delete so `old_blob` captures the prior AST.
727/// Returns `false` if the id is unknown.
728///
729/// # Errors
730///
731/// Returns `rusqlite::Error` on database failure.
732pub fn delete_node(conn: &Connection, id: &str) -> Result<bool, rusqlite::Error> {
733    let tx = conn.unchecked_transaction()?;
734
735    let prior_blob: Option<String> = tx
736        .query_row(
737            "SELECT ast_blob FROM nodes WHERE id = ?1",
738            params![id],
739            |row| row.get(0),
740        )
741        .optional()?;
742    let Some(prior_blob) = prior_blob else {
743        return Ok(false);
744    };
745
746    tx.execute(
747        "INSERT INTO audit_log (node_id, operation, old_blob, new_blob, timestamp) VALUES (?1, 'delete', ?2, NULL, ?3)",
748        params![id, prior_blob, now_iso8601()],
749    )?;
750
751    tx.execute("DELETE FROM nodes_fts WHERE node_id = ?1", params![id])?;
752    // Id-link rows pointing at this node must be removed before the
753    // node delete fires the FK ON DELETE SET NULL on `target_id` —
754    // otherwise the CHECK constraint (link_type='id' requires NOT NULL
755    // target_id) would fail. Name-link rows demote naturally to broken
756    // (target_id NULL, target_slug intact) via the same SET NULL FK.
757    tx.execute(
758        "DELETE FROM links WHERE link_type = 'id' AND target_id = ?1",
759        params![id],
760    )?;
761    tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?;
762
763    tx.commit()?;
764    Ok(true)
765}
766
767/// List nodes by tag using the node_tags join.
768///
769/// The query tag is normalized via [`normalize_tag`] so callers need not
770/// match the stored casing or separators.
771///
772/// # Errors
773///
774/// Returns `rusqlite::Error` on database failure.
775pub fn list_by_tag(conn: &Connection, tag: &str) -> Result<Vec<NodeRow>, rusqlite::Error> {
776    let tag = normalize_tag(tag);
777    let mut stmt = conn.prepare(
778        "SELECT n.id, n.title, n.ast_blob, n.created_at, n.updated_at \
779         FROM nodes n JOIN node_tags nt ON n.id = nt.node_id \
780         WHERE nt.tag = ?1 ORDER BY n.updated_at DESC",
781    )?;
782    let rows = stmt.query_map(params![tag], |row| {
783        Ok(NodeRow {
784            id: row.get(0)?,
785            title: row.get(1)?,
786            ast_blob: row.get(2)?,
787            created_at: row.get(3)?,
788            updated_at: row.get(4)?,
789        })
790    })?;
791    rows.collect()
792}
793
794/// List recent nodes, newest first.
795///
796/// # Errors
797///
798/// Returns `rusqlite::Error` on database failure.
799pub fn list_recent(conn: &Connection, limit: usize) -> Result<Vec<NodeRow>, rusqlite::Error> {
800    let mut stmt = conn.prepare(
801        "SELECT id, title, ast_blob, created_at, updated_at FROM nodes ORDER BY updated_at DESC LIMIT ?1",
802    )?;
803    let rows = stmt.query_map(params![limit as i64], |row| {
804        Ok(NodeRow {
805            id: row.get(0)?,
806            title: row.get(1)?,
807            ast_blob: row.get(2)?,
808            created_at: row.get(3)?,
809            updated_at: row.get(4)?,
810        })
811    })?;
812    rows.collect()
813}
814
815/// Paginated full enumeration. Returns `(NodeId, Title)` pairs ordered
816/// by `updated_at` DESC. `limit = 0` returns an empty vec.
817///
818/// # Errors
819///
820/// Returns `rusqlite::Error` on database failure.
821pub fn list_all_nodes(
822    conn: &Connection,
823    limit: usize,
824    offset: usize,
825) -> Result<Vec<(NodeId, Title)>, rusqlite::Error> {
826    if limit == 0 {
827        return Ok(Vec::new());
828    }
829    let mut stmt =
830        conn.prepare("SELECT id, title FROM nodes ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2")?;
831    let rows = stmt.query_map(params![limit as i64, offset as i64], |row| {
832        Ok((NodeId(row.get(0)?), Title(row.get(1)?)))
833    })?;
834    rows.collect()
835}
836
837/// Full node data: row fields plus the decoded document and tags.
838#[derive(Debug, Clone)]
839pub struct NodeFullData {
840    pub title: String,
841    pub tags: Vec<Tag>,
842    pub document: Document,
843    pub created_at: String,
844    pub updated_at: String,
845}
846
847/// Get full node data by ID, joining node_tags and decoding the sexp blob.
848///
849/// # Errors
850///
851/// Returns [`KbError::Database`] on database failure, or
852/// [`KbError::CorruptAstBlob`] when the stored blob fails to decode.
853pub fn get_node_full(conn: &Connection, id: &str) -> Result<Option<NodeFullData>, KbError> {
854    let mut stmt =
855        conn.prepare("SELECT title, ast_blob, created_at, updated_at FROM nodes WHERE id = ?1")?;
856    let mut rows = stmt.query_map(params![id], |row| {
857        Ok((
858            row.get::<_, String>(0)?,
859            row.get::<_, String>(1)?,
860            row.get::<_, String>(2)?,
861            row.get::<_, String>(3)?,
862        ))
863    })?;
864    match rows.next() {
865        Some(Ok((title, blob, created_at, updated_at))) => {
866            let doc = decode_blob(id, &blob)?;
867            let tags = get_node_tags(conn, id)?;
868            Ok(Some(NodeFullData {
869                title,
870                tags,
871                document: doc,
872                created_at,
873                updated_at,
874            }))
875        }
876        Some(Err(e)) => Err(KbError::Database(e)),
877        None => Ok(None),
878    }
879}
880
881/// Get tags for a node from the node_tags table.
882fn get_node_tags(conn: &Connection, node_id: &str) -> Result<Vec<Tag>, rusqlite::Error> {
883    let mut stmt = conn.prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
884    let rows = stmt.query_map(params![node_id], |row| row.get::<_, String>(0))?;
885    rows.map(|r| r.map(Tag)).collect()
886}
887
888/// Batch title lookup for a list of node IDs. Returns (id, title) pairs
889/// in the same order as the input.
890///
891/// # Errors
892///
893/// Returns `rusqlite::Error` on database failure.
894pub fn fetch_titles(
895    conn: &Connection,
896    ids: &[String],
897) -> Result<Vec<(String, String)>, rusqlite::Error> {
898    if ids.is_empty() {
899        return Ok(Vec::new());
900    }
901    let placeholders: Vec<String> = ids
902        .iter()
903        .enumerate()
904        .map(|(i, _)| format!("?{}", i + 1))
905        .collect();
906    let sql = format!(
907        "SELECT id, title FROM nodes WHERE id IN ({})",
908        placeholders.join(",")
909    );
910    let mut stmt = conn.prepare(&sql)?;
911    let bound: Vec<&dyn rusqlite::types::ToSql> = ids
912        .iter()
913        .map(|id| id as &dyn rusqlite::types::ToSql)
914        .collect();
915    let rows = stmt.query_map(bound.as_slice(), |row| {
916        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
917    })?;
918    let mut map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
919    for row in rows {
920        let (id, title) = row?;
921        map.insert(id, title);
922    }
923    Ok(ids
924        .iter()
925        .map(|id| (id.clone(), map.get(id).cloned().unwrap_or_default()))
926        .collect())
927}
928
929/// Full-text search via FTS5. Returns node IDs ranked by relevance.
930///
931/// # Errors
932///
933/// Returns `rusqlite::Error` on database failure.
934pub fn search_fts(conn: &Connection, query: &str) -> Result<Vec<String>, rusqlite::Error> {
935    let q = query.trim();
936    if q.is_empty() {
937        return Ok(Vec::new());
938    }
939    let terms: Vec<String> = q.split_whitespace().map(|t| format!("\"{t}\"*")).collect();
940    let fts_query = terms.join(" AND ");
941
942    let mut stmt =
943        conn.prepare("SELECT node_id FROM nodes_fts WHERE nodes_fts MATCH ?1 ORDER BY rank")?;
944    let rows = stmt.query_map(params![fts_query], |row| row.get::<_, String>(0))?;
945    let mut results = Vec::new();
946    for row in rows {
947        let id = row?;
948        if !results.contains(&id) {
949            results.push(id);
950        }
951    }
952    Ok(results)
953}
954
955// ── Hybrid search ──────────────────────────────────────────────────────
956
957/// Cosine similarity between two same-length vectors. Returns `0.0` if
958/// the lengths differ or either vector has zero norm. Mirrors the
959/// Haskell `cosineSimilarity`.
960#[must_use]
961pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
962    if a.len() != b.len() {
963        return 0.0;
964    }
965    let na = a.iter().map(|x| x * x).sum::<f32>().sqrt();
966    let nb = b.iter().map(|x| x * x).sum::<f32>().sqrt();
967    if na == 0.0 || nb == 0.0 {
968        return 0.0;
969    }
970    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
971    dot / (na * nb)
972}
973
974/// Reciprocal Rank Fusion. Each input list ranks an item by 1-indexed
975/// position; a node's score is `sum(1 / (k + rank))` across all lists
976/// in which it appears. Returns nodes sorted by descending score.
977///
978/// Tie-breaking is deterministic: equal scores are returned in
979/// ascending lexicographic order of the underlying node id.
980#[must_use]
981pub fn reciprocal_rank_fusion(k: usize, lists: &[Vec<NodeId>]) -> Vec<NodeId> {
982    let mut scores: BTreeMap<String, f64> = BTreeMap::new();
983    for list in lists {
984        for (rank0, NodeId(id)) in list.iter().enumerate() {
985            let rank = rank0 + 1;
986            #[allow(
987                clippy::cast_precision_loss,
988                reason = "(k + rank) is a small positive rank index; the f64 cast for RRF scoring loses no significant precision"
989            )]
990            let contribution = 1.0_f64 / (k + rank) as f64;
991            *scores.entry(id.clone()).or_insert(0.0) += contribution;
992        }
993    }
994    let mut entries: Vec<(String, f64)> = scores.into_iter().collect();
995    // BTreeMap iter is ascending by key; stable sort by score descending
996    // preserves ascending key order on ties.
997    entries.sort_by(|a, b| {
998        b.1.partial_cmp(&a.1)
999            .unwrap_or(Ordering::Equal)
1000            .then_with(|| a.0.cmp(&b.0))
1001    });
1002    entries.into_iter().map(|(s, _)| NodeId(s)).collect()
1003}
1004
1005/// Score every stored embedding (for `model`) by cosine similarity to
1006/// the query vector and return the node ids in descending order.
1007/// Embeddings whose blob fails to decode are skipped silently.
1008///
1009/// # Errors
1010///
1011/// Returns `rusqlite::Error` on database failure.
1012pub fn rank_by_embedding(
1013    conn: &Connection,
1014    query_vec: &[f32],
1015    model: &str,
1016) -> Result<Vec<NodeId>, rusqlite::Error> {
1017    let mut stmt = conn.prepare("SELECT node_id, embedding FROM embeddings WHERE model = ?1")?;
1018    let rows = stmt.query_map(params![model], |row| {
1019        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1020    })?;
1021    let mut scored: Vec<(String, f32)> = Vec::new();
1022    for row in rows {
1023        let (nid, blob) = row?;
1024        if let Ok(ev) = embedding::decode_embedding(&blob) {
1025            let s = cosine_similarity(query_vec, &ev);
1026            scored.push((nid, s));
1027        }
1028    }
1029    scored.sort_by(|a, b| {
1030        b.1.partial_cmp(&a.1)
1031            .unwrap_or(Ordering::Equal)
1032            .then_with(|| a.0.cmp(&b.0))
1033    });
1034    Ok(scored.into_iter().map(|(id, _)| NodeId(id)).collect())
1035}
1036
1037/// Hybrid keyword + vector search ported from Haskell `searchHybrid`.
1038///
1039/// Algorithm:
1040///
1041/// 1. Run [`search_fts`] for the keyword ranking.
1042/// 2. If `query_embedding` is `Some` and the query is **not**
1043///    (whitespace-only AND keyword list empty), rank stored embeddings
1044///    for the configured model by cosine similarity to the supplied
1045///    query vector.
1046/// 3. Fuse the two lists via [`reciprocal_rank_fusion`] with `k = 60`.
1047///
1048/// With no query embedding, the result equals the FTS ranking. With
1049/// both lists empty the result is `[]`.
1050///
1051/// The caller (axum or MCP handler) is responsible for computing the
1052/// query embedding asynchronously via the [`crate::embedding::EmbeddingClient`]
1053/// trait and passing the result here. Storage no longer talks HTTP.
1054///
1055/// # Errors
1056///
1057/// Returns `rusqlite::Error` on database failure of the keyword or
1058/// vector queries.
1059pub fn search_hybrid(
1060    conn: &Connection,
1061    query: &str,
1062    query_embedding: Option<(Vec<f32>, &str)>,
1063) -> Result<Vec<NodeId>, rusqlite::Error> {
1064    let keyword_ranked: Vec<NodeId> = search_fts(conn, query)?.into_iter().map(NodeId).collect();
1065    let vector_ranked: Vec<NodeId> = match query_embedding {
1066        None => Vec::new(),
1067        Some((qv, model)) => {
1068            if query.trim().is_empty() && keyword_ranked.is_empty() {
1069                Vec::new()
1070            } else {
1071                rank_by_embedding(conn, &qv, model)?
1072            }
1073        }
1074    };
1075    Ok(reciprocal_rank_fusion(60, &[keyword_ranked, vector_ranked]))
1076}
1077
1078// ── sqlite-vec extension loader ────────────────────────────────────────
1079
1080/// Why loading the `sqlite-vec` dynamic extension failed.
1081///
1082/// Both variants leave the connection usable for normal queries; a
1083/// caller can branch on whether enabling extension loading failed
1084/// versus the extension file itself failing to load.
1085#[derive(Debug, thiserror::Error)]
1086pub enum VecExtensionError {
1087    /// Enabling extension loading (the `LoadExtensionGuard`) failed.
1088    #[error("enable_load_extension failed: {0}")]
1089    EnableLoad(rusqlite::Error),
1090
1091    /// `load_extension` failed (file not found, ABI mismatch, &c.).
1092    #[error("load_extension failed: {0}")]
1093    LoadExtension(rusqlite::Error),
1094}
1095
1096/// Attempt to load the `sqlite-vec` dynamic extension at `path`.
1097///
1098/// Mirrors the Haskell `tryLoadVecExtension`: on any failure (file not
1099/// found, ABI mismatch, extension already loaded, &c.) returns an
1100/// [`VecExtensionError`] and leaves the connection usable. Success
1101/// returns `Ok(())`.
1102///
1103/// # Errors
1104///
1105/// Returns [`VecExtensionError`] on extension-loader failure. The
1106/// connection remains valid for normal queries either way.
1107pub fn try_load_vec_extension(conn: &Connection, path: &str) -> Result<(), VecExtensionError> {
1108    // SAFETY: rusqlite gates extension loading behind `unsafe` because a
1109    // malicious shared object could violate memory safety. `path` is a
1110    // trusted, operator-supplied sqlite-vec extension, and the
1111    // `LoadExtensionGuard` re-disables extension loading on scope exit
1112    // (including the error paths), so no load-extension capability leaks.
1113    #[allow(
1114        unsafe_code,
1115        reason = "FFI: loads the sqlite-vec dynamic extension; soundness argued in the SAFETY comment above"
1116    )]
1117    let result: Result<(), VecExtensionError> = unsafe {
1118        let _guard =
1119            rusqlite::LoadExtensionGuard::new(conn).map_err(VecExtensionError::EnableLoad)?;
1120        conn.load_extension(path, None::<&str>)
1121            .map_err(VecExtensionError::LoadExtension)?;
1122        Ok(())
1123    };
1124    result
1125}
1126
1127// ── Links (unified id + name graph) ────────────────────────────────────
1128
1129/// A single link reference extracted from a document body.
1130///
1131/// The relinker walks the document once and emits one `LinkRef` per
1132/// outgoing edge — both id-link UUID targets and name-link bracket
1133/// slugs come out of the same walk so the body is never traversed
1134/// twice.
1135#[derive(Debug, Clone, PartialEq, Eq)]
1136pub enum LinkRef {
1137    /// `[[id:UUID]]` — the target is a node id.
1138    Id(String),
1139    /// `[[slug]]` — the target is a `#+name:` slug.
1140    Name(String),
1141}
1142
1143/// Walk a [`Document`] once and extract every outgoing link reference,
1144/// both id-links and name-links, in first-seen order with per-type
1145/// deduplication. This is the single body walker the relinker uses.
1146///
1147/// Walks every nested `Inline` position — paragraphs, headings, table
1148/// cells, list items, quote blocks. Skips literal regions:
1149/// `Block::SrcBlock` / `Block::ExampleBlock` and `Inline::InlineCode` /
1150/// `Inline::Verbatim` never contribute link rows.
1151///
1152/// Classification at each link inline:
1153///   - target starts with `id:` -> `LinkRef::Id` (UUID after the scheme)
1154///   - bare slug (no `:` and no `/`, description absent) -> `LinkRef::Name`
1155///   - anything else (https links, paths, links with descriptions other
1156///     than id-links) -> skipped
1157#[must_use]
1158pub fn extract_links(doc: &Document) -> Vec<LinkRef> {
1159    let mut seen_id = HashSet::<String>::new();
1160    let mut seen_name = HashSet::<String>::new();
1161    let mut out = Vec::new();
1162    collect_links_blocks(&doc.blocks, &mut seen_id, &mut seen_name, &mut out);
1163    out
1164}
1165
1166/// Is `target` a bare bracket-style name slug (not a scheme-qualified
1167/// URI or a path)?
1168fn is_bracket_name_ref(target: &str) -> bool {
1169    !target.is_empty() && !target.contains(':') && !target.contains('/')
1170}
1171
1172fn collect_links_blocks(
1173    blocks: &[Block],
1174    seen_id: &mut HashSet<String>,
1175    seen_name: &mut HashSet<String>,
1176    out: &mut Vec<LinkRef>,
1177) {
1178    for block in blocks {
1179        match block {
1180            Block::Heading { children, .. } => {
1181                collect_links_blocks(children, seen_id, seen_name, out);
1182            }
1183            Block::Paragraph { inlines } => {
1184                for inline in inlines {
1185                    collect_links_inline(inline, seen_id, seen_name, out);
1186                }
1187            }
1188            Block::QuoteBlock { children } => {
1189                collect_links_blocks(children, seen_id, seen_name, out);
1190            }
1191            Block::List { items, .. } => {
1192                for item in items {
1193                    collect_links_blocks(&item.content, seen_id, seen_name, out);
1194                }
1195            }
1196            Block::Table { rows } => {
1197                for row in rows {
1198                    for cell in row {
1199                        for inline in &cell.inlines {
1200                            collect_links_inline(inline, seen_id, seen_name, out);
1201                        }
1202                    }
1203                }
1204            }
1205            // Literal text regions (src/example blocks) never contribute.
1206            Block::SrcBlock { .. } | Block::ExampleBlock { .. } => {}
1207            _ => {}
1208        }
1209    }
1210}
1211
1212fn collect_links_inline(
1213    inline: &Inline,
1214    seen_id: &mut HashSet<String>,
1215    seen_name: &mut HashSet<String>,
1216    out: &mut Vec<LinkRef>,
1217) {
1218    match inline {
1219        Inline::Link {
1220            target,
1221            description,
1222        } => {
1223            if let Some(rest) = target.strip_prefix("id:") {
1224                // `[[id:UUID]]` with any description shape.
1225                let id = rest.to_string();
1226                if !id.is_empty() && seen_id.insert(id.clone()) {
1227                    out.push(LinkRef::Id(id));
1228                }
1229            } else if description.is_none() && is_bracket_name_ref(target) {
1230                let slug = target.clone();
1231                if seen_name.insert(slug.clone()) {
1232                    out.push(LinkRef::Name(slug));
1233                }
1234            }
1235        }
1236        Inline::Bold(is) | Inline::Italic(is) | Inline::Strikethrough(is) => {
1237            for i in is {
1238                collect_links_inline(i, seen_id, seen_name, out);
1239            }
1240        }
1241        // Literal inline spans never contribute.
1242        Inline::InlineCode(_) | Inline::Verbatim(_) => {}
1243        _ => {}
1244    }
1245}
1246
1247/// Read the node's `#+name:` slug, if any. Whitespace-trimmed; empty
1248/// values yield `None`.
1249#[must_use]
1250pub fn extract_name_slug(doc: &Document) -> Option<String> {
1251    for block in &doc.blocks {
1252        if let Block::Keyword { name, value } = block {
1253            if name.eq_ignore_ascii_case("name") {
1254                let trimmed = value.trim();
1255                if !trimmed.is_empty() {
1256                    return Some(trimmed.to_string());
1257                }
1258            }
1259        }
1260    }
1261    None
1262}
1263
1264/// Replace this node's outgoing links (both id-links and name-links)
1265/// with whatever its [`Document`] references.
1266///
1267/// Forward references for id-links (UUIDs not yet present in `nodes`)
1268/// are silently dropped — a subsequent [`relink_all`] resolves them.
1269/// Name-links with no matching `#+name:` slug are stored explicitly
1270/// with `target_id = NULL` (broken) and can be reverse-resolved when a
1271/// matching node later appears. Runs in a single transaction.
1272///
1273/// # Errors
1274///
1275/// Returns `rusqlite::Error` on database failure.
1276pub fn relink_one(
1277    conn: &Connection,
1278    source_id: &str,
1279    document: &Document,
1280) -> Result<(), rusqlite::Error> {
1281    let tx = conn.unchecked_transaction()?;
1282    relink_one_inner(&tx, source_id, document)?;
1283    tx.commit()?;
1284    Ok(())
1285}
1286
1287/// Inner relink implementation. Caller owns the transaction. Used by
1288/// [`insert_node`] / [`update_node`] (which already have a transaction
1289/// open) and by the public [`relink_one`] (which opens one).
1290///
1291/// One walk of the document body produces both id-link and name-link
1292/// rows; the unified `links` table is the single sink for both.
1293fn relink_one_inner(
1294    conn: &Connection,
1295    source_id: &str,
1296    document: &Document,
1297) -> Result<(), rusqlite::Error> {
1298    let refs = extract_links(document);
1299    conn.execute("DELETE FROM links WHERE source_id = ?1", params![source_id])?;
1300    if refs.is_empty() {
1301        return Ok(());
1302    }
1303    let mut ins_id = conn.prepare(
1304        "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1305         VALUES (?1, 'id', ?2, NULL)",
1306    )?;
1307    let mut ins_name = conn.prepare(
1308        "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1309         VALUES (?1, 'name', ?2, ?3)",
1310    )?;
1311    for r in &refs {
1312        match r {
1313            LinkRef::Id(tgt) => {
1314                if node_exists(conn, tgt)? {
1315                    ins_id.execute(params![source_id, tgt])?;
1316                }
1317            }
1318            LinkRef::Name(slug) => {
1319                let dst_id = resolve_slug_to_id(conn, slug)?;
1320                ins_name.execute(params![source_id, dst_id, slug])?;
1321            }
1322        }
1323    }
1324    Ok(())
1325}
1326
1327fn node_exists(conn: &Connection, id: &str) -> Result<bool, rusqlite::Error> {
1328    let n: Option<i64> = conn
1329        .query_row(
1330            "SELECT 1 FROM nodes WHERE id = ?1 LIMIT 1",
1331            params![id],
1332            |r| r.get(0),
1333        )
1334        .optional()?;
1335    Ok(n.is_some())
1336}
1337
1338/// Walk every node and rebuild its outgoing links (both id-links and
1339/// name-links) under the unified schema.
1340///
1341/// Used after a bulk import to resolve forward references that were
1342/// skipped on first write and to back-resolve broken name-links once
1343/// their target slug appears. Returns `(nodes_processed, links_written)`.
1344/// Corrupt blobs increment `nodes_processed` but contribute zero links
1345/// and never panic.
1346///
1347/// # Errors
1348///
1349/// Returns `rusqlite::Error` on database failure.
1350pub fn relink_all(conn: &Connection) -> Result<(usize, usize), rusqlite::Error> {
1351    let rows: Vec<(String, String)> = {
1352        let mut stmt = conn.prepare("SELECT id, ast_blob FROM nodes")?;
1353        let mapped = stmt.query_map([], |row| {
1354            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1355        })?;
1356        mapped.collect::<Result<_, _>>()?
1357    };
1358
1359    let tx = conn.unchecked_transaction()?;
1360    tx.execute("DELETE FROM links", [])?;
1361
1362    let mut nodes_processed: usize = 0;
1363    let mut links_written: usize = 0;
1364    {
1365        let mut ins_id = tx.prepare(
1366            "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1367             VALUES (?1, 'id', ?2, NULL)",
1368        )?;
1369        let mut ins_name = tx.prepare(
1370            "INSERT OR IGNORE INTO links (source_id, link_type, target_id, target_slug) \
1371             VALUES (?1, 'name', ?2, ?3)",
1372        )?;
1373        for (nid, blob) in &rows {
1374            nodes_processed += 1;
1375            let Ok(doc) = sexp::decode_document(blob) else {
1376                continue;
1377            };
1378            for r in extract_links(&doc) {
1379                match r {
1380                    LinkRef::Id(tgt) => {
1381                        if node_exists(&tx, &tgt)? {
1382                            ins_id.execute(params![nid, &tgt])?;
1383                            links_written += 1;
1384                        }
1385                    }
1386                    LinkRef::Name(slug) => {
1387                        let dst_id = resolve_slug_to_id(&tx, &slug)?;
1388                        ins_name.execute(params![nid, dst_id, &slug])?;
1389                        links_written += 1;
1390                    }
1391                }
1392            }
1393        }
1394    }
1395
1396    tx.commit()?;
1397    Ok((nodes_processed, links_written))
1398}
1399
1400/// Find a node id whose `#+name:` slug equals `slug`. Scans the nodes
1401/// table, decoding each ast_blob just enough to read the name keyword.
1402/// Returns the first match (lexicographically smallest id on ties).
1403fn resolve_slug_to_id(conn: &Connection, slug: &str) -> Result<Option<String>, rusqlite::Error> {
1404    let mut stmt = conn.prepare("SELECT id, ast_blob FROM nodes ORDER BY id")?;
1405    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
1406    for row in rows {
1407        let (id, blob) = row?;
1408        let Ok(doc) = sexp::decode_document(&blob) else {
1409            continue;
1410        };
1411        if let Some(node_slug) = extract_name_slug(&doc) {
1412            if node_slug == slug {
1413                return Ok(Some(id));
1414            }
1415        }
1416    }
1417    Ok(None)
1418}
1419
1420/// Demote every resolved name-link row currently pointing at `node_id`
1421/// back to broken (target_id = NULL, target_slug intact). Used when a
1422/// target node's `#+name:` slug changes; the FK ON DELETE SET NULL
1423/// handles the delete case automatically.
1424fn invalidate_resolved_pointing_at(
1425    conn: &Connection,
1426    node_id: &str,
1427) -> Result<(), rusqlite::Error> {
1428    conn.execute(
1429        "UPDATE links SET target_id = NULL \
1430         WHERE link_type = 'name' AND target_id = ?1",
1431        params![node_id],
1432    )?;
1433    Ok(())
1434}
1435
1436/// Look up a node's [`Neighborhood`] (incoming + outgoing edges across
1437/// both link types). Unknown source ids return an empty neighborhood.
1438///
1439/// Edges are projected `(other_node_id, link_type)`. For outgoing
1440/// name-links that are broken (no resolved target_id), the row is
1441/// omitted from this projection — call [`get_links`] for the full
1442/// shape including broken name-links.
1443///
1444/// # Errors
1445///
1446/// Returns `rusqlite::Error` on database failure.
1447pub fn get_neighborhood(conn: &Connection, node_id: &str) -> Result<Neighborhood, rusqlite::Error> {
1448    let outgoing: Vec<(NodeId, LinkType)> = {
1449        let mut stmt = conn.prepare(
1450            "SELECT target_id, link_type FROM links \
1451             WHERE source_id = ?1 AND target_id IS NOT NULL",
1452        )?;
1453        let rows = stmt.query_map(params![node_id], |row| {
1454            Ok((NodeId(row.get(0)?), row.get::<_, LinkType>(1)?))
1455        })?;
1456        rows.collect::<Result<_, _>>()?
1457    };
1458    let incoming: Vec<(NodeId, LinkType)> = {
1459        let mut stmt =
1460            conn.prepare("SELECT source_id, link_type FROM links WHERE target_id = ?1")?;
1461        let rows = stmt.query_map(params![node_id], |row| {
1462            Ok((NodeId(row.get(0)?), row.get::<_, LinkType>(1)?))
1463        })?;
1464        rows.collect::<Result<_, _>>()?
1465    };
1466    Ok(Neighborhood { outgoing, incoming })
1467}
1468
1469/// A node's full link neighborhood under the unified graph.
1470///
1471/// Carries every column of every row — including broken name-links
1472/// that have no resolved target_id. Used by `kb links` so the verb
1473/// can surface `[[slug]] -> (broken)` rows alongside resolved edges.
1474#[derive(Debug, Clone, Default, PartialEq, Eq)]
1475pub struct LinkNeighborhood {
1476    /// Outgoing edges from this node. Each row carries `source_id =
1477    /// node_id`. Broken name-links (target_id NULL) ARE included.
1478    pub outgoing: Vec<LinkRow>,
1479    /// Incoming edges into this node. Each row has `target_id =
1480    /// node_id`; broken rows by definition have no incoming side.
1481    pub incoming: Vec<LinkRow>,
1482}
1483
1484/// Return the full outgoing and incoming link rows for a node under
1485/// the unified graph. Outgoing rows include broken name-links;
1486/// incoming rows by definition all have non-NULL target_id.
1487///
1488/// # Errors
1489///
1490/// Returns `rusqlite::Error` on database failure.
1491pub fn get_links(conn: &Connection, node_id: &str) -> Result<LinkNeighborhood, rusqlite::Error> {
1492    let outgoing: Vec<LinkRow> = {
1493        let mut stmt = conn.prepare(
1494            "SELECT source_id, link_type, target_id, target_slug FROM links \
1495             WHERE source_id = ?1 \
1496             ORDER BY link_type, target_slug, target_id",
1497        )?;
1498        let rows = stmt.query_map(params![node_id], |r| {
1499            Ok(LinkRow {
1500                source_id: r.get(0)?,
1501                link_type: r.get(1)?,
1502                target_id: r.get(2)?,
1503                target_slug: r.get(3)?,
1504            })
1505        })?;
1506        rows.collect::<Result<_, _>>()?
1507    };
1508    let incoming: Vec<LinkRow> = {
1509        let mut stmt = conn.prepare(
1510            "SELECT source_id, link_type, target_id, target_slug FROM links \
1511             WHERE target_id = ?1 \
1512             ORDER BY link_type, source_id",
1513        )?;
1514        let rows = stmt.query_map(params![node_id], |r| {
1515            Ok(LinkRow {
1516                source_id: r.get(0)?,
1517                link_type: r.get(1)?,
1518                target_id: r.get(2)?,
1519                target_slug: r.get(3)?,
1520            })
1521        })?;
1522        rows.collect::<Result<_, _>>()?
1523    };
1524    Ok(LinkNeighborhood { outgoing, incoming })
1525}
1526
1527/// List orphan nodes — nodes nothing else points at, under either link type.
1528///
1529/// A node is an orphan when no `links` row of any `link_type` has a
1530/// resolved `target_id` equal to its id. Returns rows ordered by
1531/// `updated_at DESC` so the freshest orphans surface first.
1532///
1533/// # Errors
1534///
1535/// Returns `rusqlite::Error` on database failure.
1536pub fn list_orphans(conn: &Connection) -> Result<Vec<NodeRow>, rusqlite::Error> {
1537    let mut stmt = conn.prepare(
1538        "SELECT n.id, n.title, n.ast_blob, n.created_at, n.updated_at \
1539         FROM nodes n \
1540         WHERE NOT EXISTS ( \
1541           SELECT 1 FROM links l \
1542           WHERE l.target_id = n.id \
1543         ) \
1544         ORDER BY n.updated_at DESC",
1545    )?;
1546    let rows = stmt.query_map([], |row| {
1547        Ok(NodeRow {
1548            id: row.get(0)?,
1549            title: row.get(1)?,
1550            ast_blob: row.get(2)?,
1551            created_at: row.get(3)?,
1552            updated_at: row.get(4)?,
1553        })
1554    })?;
1555    rows.collect()
1556}
1557
1558/// One hub entry: a node id, its title, and its total in-degree
1559/// across both link types in the unified graph.
1560#[derive(Debug, Clone, PartialEq, Eq)]
1561pub struct HubEntry {
1562    pub id: String,
1563    pub title: String,
1564    pub in_degree: i64,
1565}
1566
1567/// Hubs: nodes ranked by total resolved in-degree across both link
1568/// types (id-links plus resolved name-links). Limit caps the result;
1569/// `0` returns an empty vec. Ties break lexicographically on node id.
1570///
1571/// # Errors
1572///
1573/// Returns `rusqlite::Error` on database failure.
1574pub fn list_hubs(conn: &Connection, limit: usize) -> Result<Vec<HubEntry>, rusqlite::Error> {
1575    if limit == 0 {
1576        return Ok(Vec::new());
1577    }
1578    let mut stmt = conn.prepare(
1579        "SELECT n.id, n.title, COUNT(l.source_id) AS in_degree \
1580         FROM nodes n \
1581         JOIN links l ON l.target_id = n.id \
1582         GROUP BY n.id, n.title \
1583         ORDER BY in_degree DESC, n.id ASC \
1584         LIMIT ?1",
1585    )?;
1586    let rows = stmt.query_map(params![limit as i64], |r| {
1587        Ok(HubEntry {
1588            id: r.get(0)?,
1589            title: r.get(1)?,
1590            in_degree: r.get(2)?,
1591        })
1592    })?;
1593    rows.collect()
1594}
1595
1596/// Broken bracket references in the unified graph.
1597///
1598/// Rows with `link_type = 'name'` and `target_id` still NULL —
1599/// `[[name]]` mentions of a slug no node has claimed via `#+name:`.
1600/// Id-links cannot be broken (CHECK enforces NOT NULL target_id for
1601/// them). Ordered by `(target_slug, source_id)` for deterministic
1602/// output.
1603///
1604/// # Errors
1605///
1606/// Returns `rusqlite::Error` on database failure.
1607pub fn list_broken_links(conn: &Connection) -> Result<Vec<LinkRow>, rusqlite::Error> {
1608    let mut stmt = conn.prepare(
1609        "SELECT source_id, link_type, target_id, target_slug FROM links \
1610         WHERE link_type = 'name' AND target_id IS NULL \
1611         ORDER BY target_slug ASC, source_id ASC",
1612    )?;
1613    let rows = stmt.query_map([], |r| {
1614        Ok(LinkRow {
1615            source_id: r.get(0)?,
1616            link_type: r.get(1)?,
1617            target_id: r.get(2)?,
1618            target_slug: r.get(3)?,
1619        })
1620    })?;
1621    rows.collect()
1622}
1623
1624// ── AST → row-field projection ─────────────────────────────────────────
1625
1626/// Derive the `nodes.title` from an AST: a `#+title:` keyword line wins;
1627/// else the first heading title; else the first paragraph's plain text;
1628/// truncated to 80 chars; else "(untitled)".
1629#[must_use]
1630pub fn extract_title(doc: &Document) -> String {
1631    title_keyword(&doc.blocks)
1632        .or_else(|| find_first_title(&doc.blocks))
1633        .map(|t| truncate80(t.trim()))
1634        .unwrap_or_else(|| "(untitled)".into())
1635}
1636
1637/// The value of the first non-empty `#+title:` keyword line, if any.
1638fn title_keyword(blocks: &[Block]) -> Option<String> {
1639    blocks.iter().find_map(|block| match block {
1640        Block::Keyword { name, value } if name.eq_ignore_ascii_case("title") => {
1641            let trimmed = value.trim();
1642            (!trimmed.is_empty()).then(|| trimmed.to_string())
1643        }
1644        _ => None,
1645    })
1646}
1647
1648fn find_first_title(blocks: &[Block]) -> Option<String> {
1649    for block in blocks {
1650        match block {
1651            Block::Heading {
1652                title, children, ..
1653            } => {
1654                if !title.0.trim().is_empty() {
1655                    return Some(title.0.clone());
1656                }
1657                if let Some(t) = find_first_title(children) {
1658                    return Some(t);
1659                }
1660            }
1661            Block::Paragraph { inlines } => {
1662                let text = inline_text(inlines);
1663                if !text.trim().is_empty() {
1664                    return Some(text);
1665                }
1666            }
1667            Block::QuoteBlock { children } => {
1668                if let Some(t) = find_first_title(children) {
1669                    return Some(t);
1670                }
1671            }
1672            _ => {}
1673        }
1674    }
1675    None
1676}
1677
1678fn truncate80(s: &str) -> String {
1679    if s.chars().count() <= 80 {
1680        s.to_string()
1681    } else {
1682        s.chars().take(80).collect()
1683    }
1684}
1685
1686/// Union of every tag in the document — heading tags plus any
1687/// `#+filetags:` keyword lines — normalized, deduplicated, in
1688/// first-seen order. See [`normalize_tag`] for the normal form.
1689#[must_use]
1690pub fn extract_tags(doc: &Document) -> Vec<Tag> {
1691    let mut seen = HashSet::new();
1692    let mut result = Vec::new();
1693    collect_tags(&doc.blocks, &mut seen, &mut result);
1694    result
1695}
1696
1697/// Normalize a tag to lowercase kebab-case.
1698///
1699/// Alphanumeric characters are lowercased; every run of other characters
1700/// (spaces, `_`, punctuation) collapses to a single `-`, with leading and
1701/// trailing separators trimmed. camelCase is not split (`silentCritic` ->
1702/// `silentcritic`). Applied symmetrically on the write path
1703/// ([`extract_tags`]) and the query path ([`list_by_tag`]) so lookups
1704/// match regardless of how the caller cased or spaced the tag.
1705#[must_use]
1706pub fn normalize_tag(raw: &str) -> String {
1707    let mut out = String::with_capacity(raw.len());
1708    let mut pending_sep = false;
1709    for ch in raw.chars() {
1710        if ch.is_alphanumeric() {
1711            if pending_sep && !out.is_empty() {
1712                out.push('-');
1713            }
1714            pending_sep = false;
1715            out.extend(ch.to_lowercase());
1716        } else {
1717            pending_sep = true;
1718        }
1719    }
1720    out
1721}
1722
1723/// Parse an org `#+filetags:` value into bare tag names.
1724///
1725/// org-roam writes file tags colon-delimited (`:a:b:c:`); the keyword
1726/// value is verbatim, so leading/trailing padding and the surrounding
1727/// colons are stripped here.
1728fn parse_filetags(value: &str) -> impl Iterator<Item = String> + '_ {
1729    value
1730        .split(':')
1731        .map(str::trim)
1732        .filter(|s| !s.is_empty())
1733        .map(str::to_string)
1734}
1735
1736/// Normalize `raw`, then push it onto `out` if non-empty and unseen.
1737fn push_tag(raw: &str, seen: &mut HashSet<String>, out: &mut Vec<Tag>) {
1738    let tag = normalize_tag(raw);
1739    if !tag.is_empty() && seen.insert(tag.clone()) {
1740        out.push(Tag(tag));
1741    }
1742}
1743
1744fn collect_tags(blocks: &[Block], seen: &mut HashSet<String>, out: &mut Vec<Tag>) {
1745    for block in blocks {
1746        match block {
1747            Block::Heading { tags, children, .. } => {
1748                for tag in tags {
1749                    push_tag(&tag.0, seen, out);
1750                }
1751                collect_tags(children, seen, out);
1752            }
1753            Block::QuoteBlock { children } => {
1754                collect_tags(children, seen, out);
1755            }
1756            Block::Keyword { name, value } if name.eq_ignore_ascii_case("filetags") => {
1757                for tag in parse_filetags(value) {
1758                    push_tag(&tag, seen, out);
1759                }
1760            }
1761            _ => {}
1762        }
1763    }
1764}
1765
1766/// Extract all plain text from a document for FTS5 body indexing.
1767pub fn extract_body_text(doc: &Document) -> String {
1768    let mut texts = Vec::new();
1769    collect_texts(&doc.blocks, &mut texts);
1770    texts
1771        .into_iter()
1772        .filter(|s| !s.is_empty())
1773        .collect::<Vec<_>>()
1774        .join(" ")
1775}
1776
1777fn collect_texts(blocks: &[Block], out: &mut Vec<String>) {
1778    for block in blocks {
1779        match block {
1780            Block::Heading { children, .. } => collect_texts(children, out),
1781            Block::Paragraph { inlines } => {
1782                out.push(inline_text(inlines));
1783            }
1784            Block::SrcBlock { content, .. } | Block::ExampleBlock { content } => {
1785                out.push(content.clone());
1786            }
1787            Block::QuoteBlock { children } => collect_texts(children, out),
1788            Block::List { items, .. } => {
1789                for item in items {
1790                    collect_texts(&item.content, out);
1791                }
1792            }
1793            Block::Table { rows } => {
1794                for row in rows {
1795                    for cell in row {
1796                        out.push(inline_text(&cell.inlines));
1797                    }
1798                }
1799            }
1800            Block::PropertyDrawer { entries } => {
1801                for (_, v) in entries {
1802                    out.push(v.clone());
1803                }
1804            }
1805            Block::LogbookDrawer { entries } => {
1806                for entry in entries {
1807                    out.push(entry.note.clone());
1808                }
1809            }
1810            Block::Comment { text } => {
1811                out.push(text.clone());
1812            }
1813            Block::Keyword { value, .. } => {
1814                out.push(value.clone());
1815            }
1816            Block::Planning { .. } | Block::BlankLine | Block::HorizontalRule => {}
1817        }
1818    }
1819}
1820
1821fn inline_text(inlines: &[Inline]) -> String {
1822    let mut parts = Vec::new();
1823    for inline in inlines {
1824        match inline {
1825            Inline::Plain(t) => parts.push(t.clone()),
1826            Inline::Bold(is) | Inline::Italic(is) | Inline::Strikethrough(is) => {
1827                parts.push(inline_text(is));
1828            }
1829            Inline::InlineCode(t) | Inline::Verbatim(t) => parts.push(t.clone()),
1830            Inline::Link {
1831                description: Some(d),
1832                ..
1833            } => parts.push(d.clone()),
1834            Inline::Link { target, .. } => parts.push(target.clone()),
1835            Inline::LineBreak => parts.push(" ".to_string()),
1836        }
1837    }
1838    parts.join("")
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843    use super::*;
1844
1845    fn setup() -> Connection {
1846        let conn = Connection::open_in_memory().unwrap();
1847        conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
1848        init_db(&conn).unwrap();
1849        conn
1850    }
1851
1852    fn sample_doc() -> Document {
1853        Document {
1854            blocks: vec![Block::Heading {
1855                level: 1,
1856                title: Title("Test".into()),
1857                tags: vec![Tag("rust".into())],
1858                children: vec![Block::Paragraph {
1859                    inlines: vec![Inline::Plain("hello world".into())],
1860                }],
1861            }],
1862        }
1863    }
1864
1865    fn link_doc(targets: &[&str]) -> Document {
1866        let inlines: Vec<Inline> = targets
1867            .iter()
1868            .map(|t| Inline::Link {
1869                target: format!("id:{t}"),
1870                description: None,
1871            })
1872            .collect();
1873        Document {
1874            blocks: vec![Block::Paragraph { inlines }],
1875        }
1876    }
1877
1878    // ── Basic ops (unchanged behaviour) ────────────────────────────────
1879
1880    #[test]
1881    fn insert_and_get_node() {
1882        let conn = setup();
1883        let id = "test-1";
1884        insert_node(&conn, id, &sample_doc()).unwrap();
1885        let doc = get_node(&conn, id).unwrap().unwrap();
1886        assert_eq!(doc, sample_doc());
1887    }
1888
1889    #[test]
1890    fn get_nonexistent_node() {
1891        let conn = setup();
1892        let doc = get_node(&conn, "no-such-id").unwrap();
1893        assert!(doc.is_none());
1894    }
1895
1896    #[test]
1897    fn update_node_works() {
1898        let conn = setup();
1899        let id = "test-2";
1900        insert_node(&conn, id, &sample_doc()).unwrap();
1901        let mut updated_doc = sample_doc();
1902        updated_doc.blocks.push(Block::Paragraph {
1903            inlines: vec![Inline::Plain("extra".into())],
1904        });
1905        let ok = update_node(&conn, id, &updated_doc).unwrap();
1906        assert!(ok);
1907        let doc = get_node(&conn, id).unwrap().unwrap();
1908        assert_eq!(doc, updated_doc);
1909    }
1910
1911    #[test]
1912    fn update_nonexistent_returns_false() {
1913        let conn = setup();
1914        let ok = update_node(&conn, "no-such", &sample_doc()).unwrap();
1915        assert!(!ok);
1916    }
1917
1918    #[test]
1919    fn delete_node_works() {
1920        let conn = setup();
1921        let id = "test-3";
1922        insert_node(&conn, id, &sample_doc()).unwrap();
1923        let ok = delete_node(&conn, id).unwrap();
1924        assert!(ok);
1925        assert!(get_node(&conn, id).unwrap().is_none());
1926    }
1927
1928    #[test]
1929    fn delete_nonexistent_returns_false() {
1930        let conn = setup();
1931        let ok = delete_node(&conn, "no-such").unwrap();
1932        assert!(!ok);
1933    }
1934
1935    #[test]
1936    fn list_by_tag_finds_match() {
1937        let conn = setup();
1938        insert_node(&conn, "a", &sample_doc()).unwrap();
1939
1940        let doc_b = Document {
1941            blocks: vec![Block::Heading {
1942                level: 1,
1943                title: Title("Other".into()),
1944                tags: vec![Tag("python".into())],
1945                children: vec![],
1946            }],
1947        };
1948        insert_node(&conn, "b", &doc_b).unwrap();
1949
1950        let results = list_by_tag(&conn, "rust").unwrap();
1951        assert_eq!(results.len(), 1);
1952        assert_eq!(results[0].id.as_str(), "a");
1953    }
1954
1955    #[test]
1956    fn list_recent_newest_first() {
1957        let conn = setup();
1958        insert_node(&conn, "first", &sample_doc()).unwrap();
1959        std::thread::sleep(std::time::Duration::from_millis(10));
1960        insert_node(&conn, "second", &sample_doc()).unwrap();
1961        let recent = list_recent(&conn, 10).unwrap();
1962        assert_eq!(recent.first().unwrap().id.as_str(), "second");
1963    }
1964
1965    #[test]
1966    fn title_from_first_heading() {
1967        let doc = Document {
1968            blocks: vec![Block::Heading {
1969                level: 1,
1970                title: Title("My Note".into()),
1971                tags: vec![],
1972                children: vec![],
1973            }],
1974        };
1975        assert_eq!(extract_title(&doc), "My Note");
1976    }
1977
1978    #[test]
1979    fn title_from_paragraph_fallback() {
1980        let doc = Document {
1981            blocks: vec![Block::Paragraph {
1982                inlines: vec![Inline::Plain("First paragraph text".into())],
1983            }],
1984        };
1985        assert_eq!(extract_title(&doc), "First paragraph text");
1986    }
1987
1988    #[test]
1989    fn title_untitled_when_empty() {
1990        let doc = Document { blocks: vec![] };
1991        assert_eq!(extract_title(&doc), "(untitled)");
1992    }
1993
1994    #[test]
1995    fn title_truncated_at_80() {
1996        let long = "a".repeat(100);
1997        let doc = Document {
1998            blocks: vec![Block::Paragraph {
1999                inlines: vec![Inline::Plain(long)],
2000            }],
2001        };
2002        let title = extract_title(&doc);
2003        assert_eq!(title.chars().count(), 80);
2004    }
2005
2006    #[test]
2007    fn extract_tags_collects_all() {
2008        let doc = Document {
2009            blocks: vec![
2010                Block::Heading {
2011                    level: 1,
2012                    title: Title("A".into()),
2013                    tags: vec![Tag("rust".into()), Tag("kb".into())],
2014                    children: vec![Block::Heading {
2015                        level: 2,
2016                        title: Title("B".into()),
2017                        tags: vec![Tag("testing".into())],
2018                        children: vec![],
2019                    }],
2020                },
2021                Block::Heading {
2022                    level: 1,
2023                    title: Title("C".into()),
2024                    tags: vec![Tag("rust".into())], // duplicate
2025                    children: vec![],
2026                },
2027            ],
2028        };
2029        let tags = extract_tags(&doc);
2030        assert_eq!(tags.len(), 3);
2031        assert_eq!(tags[0].0, "rust");
2032        assert_eq!(tags[1].0, "kb");
2033        assert_eq!(tags[2].0, "testing");
2034    }
2035
2036    #[test]
2037    fn extract_tags_includes_filetags_keyword() {
2038        // org-roam document shape: a `#+filetags:` line, no heading tags.
2039        let doc = Document {
2040            blocks: vec![
2041                Block::Keyword {
2042                    name: "filetags".into(),
2043                    value: " :design:claude-memory:project:adr:".into(),
2044                },
2045                Block::Heading {
2046                    level: 1,
2047                    title: Title("Decision".into()),
2048                    tags: vec![Tag("design".into())], // duplicate of a filetag
2049                    children: vec![],
2050                },
2051            ],
2052        };
2053        let extracted = extract_tags(&doc);
2054        let tags: Vec<&str> = extracted.iter().map(|t| t.0.as_str()).collect();
2055        assert_eq!(tags, ["design", "claude-memory", "project", "adr"]);
2056    }
2057
2058    #[test]
2059    fn normalize_tag_lowercase_kebab() {
2060        assert_eq!(normalize_tag("Rust"), "rust");
2061        assert_eq!(normalize_tag("Claude Memory"), "claude-memory");
2062        assert_eq!(normalize_tag("claude_memory"), "claude-memory");
2063        assert_eq!(normalize_tag("silent-critic"), "silent-critic");
2064        assert_eq!(normalize_tag("silentCritic"), "silentcritic");
2065        assert_eq!(normalize_tag("  spaced  tag  "), "spaced-tag");
2066        assert_eq!(normalize_tag("+++"), "");
2067    }
2068
2069    #[test]
2070    fn extract_tags_normalizes_heading_tags() {
2071        let doc = Document {
2072            blocks: vec![Block::Heading {
2073                level: 1,
2074                title: Title("H".into()),
2075                tags: vec![Tag("Rust".into()), Tag("rust".into())],
2076                children: vec![],
2077            }],
2078        };
2079        let tags = extract_tags(&doc);
2080        // Both collapse to one normalized tag.
2081        assert_eq!(tags.len(), 1);
2082        assert_eq!(tags[0].0, "rust");
2083    }
2084
2085    #[test]
2086    fn extract_title_prefers_title_keyword() {
2087        let doc = Document {
2088            blocks: vec![
2089                Block::Keyword {
2090                    name: "title".into(),
2091                    value: " The Real Title".into(),
2092                },
2093                Block::Heading {
2094                    level: 1,
2095                    title: Title("First Heading".into()),
2096                    tags: vec![],
2097                    children: vec![],
2098                },
2099            ],
2100        };
2101        assert_eq!(extract_title(&doc), "The Real Title");
2102    }
2103
2104    #[test]
2105    fn extract_title_falls_back_to_heading_without_keyword() {
2106        let doc = Document {
2107            blocks: vec![Block::Heading {
2108                level: 1,
2109                title: Title("First Heading".into()),
2110                tags: vec![],
2111                children: vec![],
2112            }],
2113        };
2114        assert_eq!(extract_title(&doc), "First Heading");
2115    }
2116
2117    #[test]
2118    fn sexp_blob_roundtrip() {
2119        let conn = setup();
2120        let id = "sexp-test";
2121        insert_node(&conn, id, &sample_doc()).unwrap();
2122        let row = get_node_row(&conn, id).unwrap().unwrap();
2123        assert!(row.ast_blob.starts_with("(kb-doc 1"));
2124        let decoded = sexp::decode_document(&row.ast_blob).unwrap();
2125        assert_eq!(decoded, sample_doc());
2126    }
2127
2128    // ── Schema parity ──────────────────────────────────────────────────
2129
2130    #[test]
2131    fn schema_version_constant_is_two() {
2132        assert_eq!(CURRENT_SCHEMA_VERSION, 2);
2133    }
2134
2135    #[test]
2136    fn migration_reencodes_legacy_ordered_list_blob() {
2137        let conn = setup();
2138        let id = "11111111-1111-1111-1111-111111111111";
2139        insert_node(&conn, id, &sample_doc()).unwrap();
2140        // Rewrite the row to the v1 blob format (bare `ordered` symbol)
2141        // and roll user_version back so the v2 migration re-runs.
2142        let legacy = "(kb-doc 1 (list ordered (item no-checkbox (paragraph (plain \"x\")))))";
2143        conn.execute(
2144            "UPDATE nodes SET ast_blob = ?1 WHERE id = ?2",
2145            params![legacy, id],
2146        )
2147        .unwrap();
2148        conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2149
2150        init_db(&conn).unwrap();
2151
2152        let row = get_node_row(&conn, id).unwrap().unwrap();
2153        assert!(row.ast_blob.contains("(list (ordered 1)"));
2154        let full = get_node_full(&conn, id).unwrap().unwrap();
2155        assert_eq!(
2156            full.document.blocks,
2157            vec![Block::List {
2158                list_type: ListType::Ordered(1),
2159                items: vec![ListItem {
2160                    content: vec![Block::Paragraph {
2161                        inlines: vec![Inline::Plain("x".into())],
2162                    }],
2163                    checkbox: Checkbox::NoCheckbox,
2164                }],
2165            }]
2166        );
2167    }
2168
2169    #[test]
2170    fn migration_skipped_at_current_version() {
2171        let conn = setup();
2172        let id = "22222222-2222-2222-2222-222222222222";
2173        insert_node(&conn, id, &sample_doc()).unwrap();
2174        // At user_version 2 the blob pass must not run: a legacy-format
2175        // blob planted now survives a second init_db untouched.
2176        let legacy = "(kb-doc 1 (list ordered (item no-checkbox (paragraph (plain \"x\")))))";
2177        conn.execute(
2178            "UPDATE nodes SET ast_blob = ?1 WHERE id = ?2",
2179            params![legacy, id],
2180        )
2181        .unwrap();
2182
2183        init_db(&conn).unwrap();
2184
2185        let row = get_node_row(&conn, id).unwrap().unwrap();
2186        assert_eq!(row.ast_blob, legacy);
2187    }
2188
2189    #[test]
2190    fn get_node_reports_corrupt_blob() {
2191        let conn = setup();
2192        let id = "44444444-4444-4444-4444-444444444444";
2193        insert_node(&conn, id, &sample_doc()).unwrap();
2194        conn.execute(
2195            "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2196            params![id],
2197        )
2198        .unwrap();
2199        match get_node(&conn, id).unwrap_err() {
2200            KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2201            other => panic!("expected CorruptAstBlob, got {other}"),
2202        }
2203    }
2204
2205    #[test]
2206    fn get_node_full_reports_corrupt_blob() {
2207        let conn = setup();
2208        let id = "55555555-5555-5555-5555-555555555555";
2209        insert_node(&conn, id, &sample_doc()).unwrap();
2210        conn.execute(
2211            "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2212            params![id],
2213        )
2214        .unwrap();
2215        match get_node_full(&conn, id).unwrap_err() {
2216            KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2217            other => panic!("expected CorruptAstBlob, got {other}"),
2218        }
2219    }
2220
2221    #[test]
2222    fn migration_reports_corrupt_blob() {
2223        let conn = setup();
2224        let id = "33333333-3333-3333-3333-333333333333";
2225        insert_node(&conn, id, &sample_doc()).unwrap();
2226        conn.execute(
2227            "UPDATE nodes SET ast_blob = '(not a kb-doc' WHERE id = ?1",
2228            params![id],
2229        )
2230        .unwrap();
2231        conn.execute_batch("PRAGMA user_version = 1;").unwrap();
2232
2233        let err = init_db(&conn).unwrap_err();
2234        match err {
2235            KbError::CorruptAstBlob { node_id, .. } => assert_eq!(node_id, id),
2236            other => panic!("expected CorruptAstBlob, got {other}"),
2237        }
2238    }
2239
2240    #[test]
2241    fn schema_version_pragma_is_stamped() {
2242        let conn = setup();
2243        let v: i64 = conn
2244            .query_row("PRAGMA user_version", [], |r| r.get(0))
2245            .unwrap();
2246        assert_eq!(v, i64::from(CURRENT_SCHEMA_VERSION));
2247    }
2248
2249    #[test]
2250    fn schema_links_table_columns_and_pk() {
2251        let conn = setup();
2252        let cols: Vec<(String, String, i64, i64)> = conn
2253            .prepare("PRAGMA table_info(links)")
2254            .unwrap()
2255            .query_map([], |r| {
2256                Ok((
2257                    r.get::<_, String>(1)?, // name
2258                    r.get::<_, String>(2)?, // type
2259                    r.get::<_, i64>(3)?,    // notnull
2260                    r.get::<_, i64>(5)?,    // pk
2261                ))
2262            })
2263            .unwrap()
2264            .collect::<Result<_, _>>()
2265            .unwrap();
2266        // Unified schema: source_id NOT NULL, link_type NOT NULL,
2267        // target_id NULLABLE (set NULL on target delete for name-links),
2268        // target_slug NULLABLE (NULL for id-links, NOT NULL for
2269        // name-links — enforced by CHECK). PK composite over all four.
2270        assert_eq!(
2271            cols,
2272            vec![
2273                ("source_id".into(), "TEXT".into(), 1, 1),
2274                ("link_type".into(), "TEXT".into(), 1, 2),
2275                ("target_id".into(), "TEXT".into(), 0, 3),
2276                ("target_slug".into(), "TEXT".into(), 0, 4),
2277            ]
2278        );
2279    }
2280
2281    #[test]
2282    fn schema_links_table_foreign_keys() {
2283        let conn = setup();
2284        let fks: Vec<(String, String, String, String)> = conn
2285            .prepare("PRAGMA foreign_key_list(links)")
2286            .unwrap()
2287            .query_map([], |r| {
2288                Ok((
2289                    r.get::<_, String>(2)?, // table
2290                    r.get::<_, String>(3)?, // from
2291                    r.get::<_, String>(4)?, // to
2292                    r.get::<_, String>(6)?, // on_delete
2293                ))
2294            })
2295            .unwrap()
2296            .collect::<Result<_, _>>()
2297            .unwrap();
2298        assert_eq!(fks.len(), 2);
2299        let mut by_from: std::collections::HashMap<String, (String, String, String)> =
2300            std::collections::HashMap::new();
2301        for (table, from, to, on_delete) in fks {
2302            by_from.insert(from, (table, to, on_delete));
2303        }
2304        let src = by_from.get("source_id").expect("source_id FK present");
2305        assert_eq!(src.0, "nodes");
2306        assert_eq!(src.1, "id");
2307        assert_eq!(src.2, "CASCADE");
2308        let tgt = by_from.get("target_id").expect("target_id FK present");
2309        assert_eq!(tgt.0, "nodes");
2310        assert_eq!(tgt.1, "id");
2311        // Name-link rows demote to broken when the target node is
2312        // deleted; id-link rows pointing at the deleted node are
2313        // removed manually in `delete_node` before the cascade fires.
2314        assert_eq!(tgt.2, "SET NULL");
2315    }
2316
2317    #[test]
2318    fn links_table_check_constraint_rejects_invalid_shapes() {
2319        let conn = setup();
2320        // Need a source node so the FK on source_id holds.
2321        insert_node(&conn, "src", &empty_doc()).unwrap();
2322        // id-link with NULL target_id -> CHECK violation.
2323        let e = conn.execute(
2324            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2325             VALUES ('src', 'id', NULL, NULL)",
2326            [],
2327        );
2328        assert!(e.is_err());
2329        // id-link with target_slug set -> CHECK violation.
2330        let e = conn.execute(
2331            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2332             VALUES ('src', 'id', 'src', 'slug')",
2333            [],
2334        );
2335        assert!(e.is_err());
2336        // name-link with NULL target_slug -> CHECK violation.
2337        let e = conn.execute(
2338            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
2339             VALUES ('src', 'name', NULL, NULL)",
2340            [],
2341        );
2342        assert!(e.is_err());
2343    }
2344
2345    #[test]
2346    fn schema_name_links_table_does_not_exist() {
2347        let conn = setup();
2348        let n: i64 = conn
2349            .query_row(
2350                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
2351                [],
2352                |r| r.get(0),
2353            )
2354            .unwrap();
2355        assert_eq!(n, 0, "pre-consolidation name_links table must not exist");
2356    }
2357
2358    #[test]
2359    fn schema_links_target_index_present() {
2360        let conn = setup();
2361        let n: i64 = conn
2362            .query_row(
2363                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='links_target_idx' AND tbl_name='links'",
2364                [],
2365                |r| r.get(0),
2366            )
2367            .unwrap();
2368        assert_eq!(n, 1);
2369    }
2370
2371    #[test]
2372    fn schema_audit_log_columns() {
2373        let conn = setup();
2374        let cols: Vec<(String, String, i64, i64)> = conn
2375            .prepare("PRAGMA table_info(audit_log)")
2376            .unwrap()
2377            .query_map([], |r| {
2378                Ok((
2379                    r.get::<_, String>(1)?, // name
2380                    r.get::<_, String>(2)?, // type
2381                    r.get::<_, i64>(3)?,    // notnull
2382                    r.get::<_, i64>(5)?,    // pk
2383                ))
2384            })
2385            .unwrap()
2386            .collect::<Result<_, _>>()
2387            .unwrap();
2388        assert_eq!(
2389            cols,
2390            vec![
2391                ("id".into(), "INTEGER".into(), 0, 1),
2392                ("node_id".into(), "TEXT".into(), 1, 0),
2393                ("operation".into(), "TEXT".into(), 1, 0),
2394                ("old_blob".into(), "TEXT".into(), 0, 0),
2395                ("new_blob".into(), "TEXT".into(), 0, 0),
2396                ("timestamp".into(), "TEXT".into(), 1, 0),
2397            ]
2398        );
2399    }
2400
2401    #[test]
2402    fn schema_audit_log_has_no_foreign_keys() {
2403        let conn = setup();
2404        let n: i64 = conn
2405            .query_row(
2406                "SELECT COUNT(*) FROM pragma_foreign_key_list('audit_log')",
2407                [],
2408                |r| r.get(0),
2409            )
2410            .unwrap();
2411        assert_eq!(n, 0);
2412    }
2413
2414    #[test]
2415    fn schema_audit_log_autoincrement_works() {
2416        let conn = setup();
2417        conn.execute(
2418            "INSERT INTO audit_log (node_id, operation, timestamp) VALUES ('x', 'insert', 'now')",
2419            [],
2420        )
2421        .unwrap();
2422        conn.execute(
2423            "INSERT INTO audit_log (node_id, operation, timestamp) VALUES ('y', 'insert', 'now')",
2424            [],
2425        )
2426        .unwrap();
2427        let ids: Vec<i64> = conn
2428            .prepare("SELECT id FROM audit_log ORDER BY id")
2429            .unwrap()
2430            .query_map([], |r| r.get::<_, i64>(0))
2431            .unwrap()
2432            .collect::<Result<_, _>>()
2433            .unwrap();
2434        assert_eq!(ids, vec![1, 2]);
2435        // sqlite_sequence is created only when AUTOINCREMENT is in effect.
2436        let n: i64 = conn
2437            .query_row(
2438                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'",
2439                [],
2440                |r| r.get(0),
2441            )
2442            .unwrap();
2443        assert_eq!(n, 1);
2444    }
2445
2446    #[test]
2447    fn schema_audit_log_indices_present() {
2448        let conn = setup();
2449        for name in ["audit_log_node_idx", "audit_log_ts_idx"] {
2450            let n: i64 = conn
2451                .query_row(
2452                    "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?1 AND tbl_name='audit_log'",
2453                    params![name],
2454                    |r| r.get(0),
2455                )
2456                .unwrap();
2457            assert_eq!(n, 1, "missing index {name}");
2458        }
2459    }
2460
2461    // ── Audit log writers ──────────────────────────────────────────────
2462
2463    fn fetch_audit_rows(conn: &Connection, node_id: &str) -> Vec<AuditRow> {
2464        conn.prepare(
2465            "SELECT id, node_id, operation, old_blob, new_blob, timestamp FROM audit_log WHERE node_id = ?1 ORDER BY id",
2466        )
2467        .unwrap()
2468        .query_map(params![node_id], |r| {
2469            Ok(AuditRow {
2470                id: r.get(0)?,
2471                node_id: r.get(1)?,
2472                operation: r.get(2)?,
2473                old_blob: r.get(3)?,
2474                new_blob: r.get(4)?,
2475                timestamp: r.get(5)?,
2476            })
2477        })
2478        .unwrap()
2479        .collect::<Result<_, _>>()
2480        .unwrap()
2481    }
2482
2483    #[test]
2484    fn audit_writers_insert_writes_one_row() {
2485        let conn = setup();
2486        insert_node(&conn, "n", &sample_doc()).unwrap();
2487        let rows = fetch_audit_rows(&conn, "n");
2488        assert_eq!(rows.len(), 1);
2489        assert_eq!(rows[0].operation, "insert");
2490        assert!(rows[0].old_blob.is_none());
2491        assert!(
2492            rows[0]
2493                .new_blob
2494                .as_deref()
2495                .unwrap()
2496                .starts_with("(kb-doc 1")
2497        );
2498        // ISO8601 with millisecond precision and Z suffix.
2499        let ts = &rows[0].timestamp;
2500        assert!(
2501            ts.ends_with('Z') && ts.contains('.'),
2502            "timestamp not ISO8601-ms: {ts}"
2503        );
2504    }
2505
2506    #[test]
2507    fn audit_writers_update_records_old_and_new() {
2508        let conn = setup();
2509        insert_node(&conn, "n", &sample_doc()).unwrap();
2510        let mut next = sample_doc();
2511        next.blocks.push(Block::Paragraph {
2512            inlines: vec![Inline::Plain("more".into())],
2513        });
2514        update_node(&conn, "n", &next).unwrap();
2515        let rows = fetch_audit_rows(&conn, "n");
2516        assert_eq!(rows.len(), 2);
2517        assert_eq!(rows[1].operation, "update");
2518        let old = rows[1].old_blob.as_deref().unwrap();
2519        let new = rows[1].new_blob.as_deref().unwrap();
2520        assert_ne!(old, new);
2521        assert!(new.contains("more"));
2522    }
2523
2524    #[test]
2525    fn audit_writers_delete_records_old_only() {
2526        let conn = setup();
2527        insert_node(&conn, "n", &sample_doc()).unwrap();
2528        delete_node(&conn, "n").unwrap();
2529        let rows = fetch_audit_rows(&conn, "n");
2530        // History survives the delete (audit_log has no FK).
2531        assert_eq!(rows.len(), 2);
2532        assert_eq!(rows[1].operation, "delete");
2533        assert!(rows[1].old_blob.is_some());
2534        assert!(rows[1].new_blob.is_none());
2535    }
2536
2537    #[test]
2538    fn audit_writers_timestamp_format_matches_node_created_at() {
2539        let conn = setup();
2540        insert_node(&conn, "n", &sample_doc()).unwrap();
2541        let node_ts: String = conn
2542            .query_row("SELECT created_at FROM nodes WHERE id = 'n'", [], |r| {
2543                r.get(0)
2544            })
2545            .unwrap();
2546        let audit_ts: String = conn
2547            .query_row(
2548                "SELECT timestamp FROM audit_log WHERE node_id = 'n'",
2549                [],
2550                |r| r.get(0),
2551            )
2552            .unwrap();
2553        assert!(is_iso8601_ms_z(&node_ts), "node ts: {node_ts}");
2554        assert!(is_iso8601_ms_z(&audit_ts), "audit ts: {audit_ts}");
2555    }
2556
2557    /// Match `YYYY-MM-DDTHH:MM:SS.fffZ` (millisecond precision, UTC).
2558    fn is_iso8601_ms_z(s: &str) -> bool {
2559        let bytes = s.as_bytes();
2560        if bytes.len() != 24 {
2561            return false;
2562        }
2563        // Expected layout (1-indexed positions):
2564        //  YYYY = 0..4    digits
2565        //   '-' = 4
2566        //   MM  = 5..7    digits
2567        //   '-' = 7
2568        //   DD  = 8..10   digits
2569        //   'T' = 10
2570        //   HH  = 11..13  digits
2571        //   ':' = 13
2572        //   MM  = 14..16  digits
2573        //   ':' = 16
2574        //   SS  = 17..19  digits
2575        //   '.' = 19
2576        //   fff = 20..23  digits
2577        //   'Z' = 23
2578        let digits =
2579            |range: std::ops::Range<usize>| range.into_iter().all(|i| bytes[i].is_ascii_digit());
2580        digits(0..4)
2581            && bytes[4] == b'-'
2582            && digits(5..7)
2583            && bytes[7] == b'-'
2584            && digits(8..10)
2585            && bytes[10] == b'T'
2586            && digits(11..13)
2587            && bytes[13] == b':'
2588            && digits(14..16)
2589            && bytes[16] == b':'
2590            && digits(17..19)
2591            && bytes[19] == b'.'
2592            && digits(20..23)
2593            && bytes[23] == b'Z'
2594    }
2595
2596    /// Id-link extraction walks every nested Block position
2597    /// (heading children, quote blocks, list items, table cells).
2598    /// Preserved from the dropped `extract_id_links_walks_nested_positions`
2599    /// shim test since the `link_parser_extracts_walks_nested_inline_positions`
2600    /// test only exercises nested `Inline` positions (Bold/Italic), not
2601    /// nested `Block` positions.
2602    #[test]
2603    fn extract_links_walks_nested_block_positions() {
2604        let inner_link = Inline::Link {
2605            target: "id:nested".into(),
2606            description: Some("nested".into()),
2607        };
2608        let doc = Document {
2609            blocks: vec![
2610                Block::Heading {
2611                    level: 1,
2612                    title: Title("h".into()),
2613                    tags: vec![],
2614                    children: vec![Block::Paragraph {
2615                        inlines: vec![Inline::Bold(vec![inner_link])],
2616                    }],
2617                },
2618                Block::QuoteBlock {
2619                    children: vec![Block::Paragraph {
2620                        inlines: vec![Inline::Italic(vec![Inline::Link {
2621                            target: "id:in_quote".into(),
2622                            description: None,
2623                        }])],
2624                    }],
2625                },
2626                Block::List {
2627                    list_type: ListType::Unordered,
2628                    items: vec![ListItem {
2629                        content: vec![Block::Paragraph {
2630                            inlines: vec![Inline::Link {
2631                                target: "id:in_list".into(),
2632                                description: None,
2633                            }],
2634                        }],
2635                        checkbox: Checkbox::NoCheckbox,
2636                    }],
2637                },
2638                Block::Table {
2639                    rows: vec![vec![TableCell {
2640                        inlines: vec![Inline::Link {
2641                            target: "id:in_table".into(),
2642                            description: None,
2643                        }],
2644                    }]],
2645                },
2646            ],
2647        };
2648        let ids: Vec<String> = extract_links(&doc)
2649            .into_iter()
2650            .filter_map(|l| match l {
2651                LinkRef::Id(s) => Some(s),
2652                LinkRef::Name(_) => None,
2653            })
2654            .collect();
2655        assert_eq!(
2656            ids,
2657            vec![
2658                "nested".to_string(),
2659                "in_quote".to_string(),
2660                "in_list".to_string(),
2661                "in_table".to_string(),
2662            ]
2663        );
2664    }
2665
2666    // ── relink_one ─────────────────────────────────────────────────────
2667
2668    fn empty_doc() -> Document {
2669        Document { blocks: vec![] }
2670    }
2671
2672    #[test]
2673    fn relink_one_inserts_existing_targets() {
2674        let conn = setup();
2675        insert_node(&conn, "src", &empty_doc()).unwrap();
2676        insert_node(&conn, "tgt1", &empty_doc()).unwrap();
2677        insert_node(&conn, "tgt2", &empty_doc()).unwrap();
2678        relink_one(&conn, "src", &link_doc(&["tgt1", "tgt2"])).unwrap();
2679
2680        let mut rows: Vec<(String, String)> = conn
2681            .prepare("SELECT target_id, link_type FROM links WHERE source_id = 'src'")
2682            .unwrap()
2683            .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
2684            .unwrap()
2685            .collect::<Result<_, _>>()
2686            .unwrap();
2687        rows.sort();
2688        assert_eq!(
2689            rows,
2690            vec![
2691                ("tgt1".to_string(), "id".to_string()),
2692                ("tgt2".to_string(), "id".to_string()),
2693            ]
2694        );
2695    }
2696
2697    #[test]
2698    fn relink_one_drops_forward_refs() {
2699        let conn = setup();
2700        insert_node(&conn, "src", &empty_doc()).unwrap();
2701        // tgt-missing does not exist as a node yet
2702        relink_one(&conn, "src", &link_doc(&["tgt-missing"])).unwrap();
2703        let n: i64 = conn
2704            .query_row(
2705                "SELECT COUNT(*) FROM links WHERE source_id = 'src'",
2706                [],
2707                |r| r.get(0),
2708            )
2709            .unwrap();
2710        assert_eq!(n, 0);
2711    }
2712
2713    #[test]
2714    fn relink_one_replaces_prior_outgoing() {
2715        let conn = setup();
2716        insert_node(&conn, "src", &empty_doc()).unwrap();
2717        insert_node(&conn, "old", &empty_doc()).unwrap();
2718        insert_node(&conn, "new", &empty_doc()).unwrap();
2719        relink_one(&conn, "src", &link_doc(&["old"])).unwrap();
2720        relink_one(&conn, "src", &link_doc(&["new"])).unwrap();
2721        let rows: Vec<String> = conn
2722            .prepare("SELECT target_id FROM links WHERE source_id = 'src'")
2723            .unwrap()
2724            .query_map([], |r| r.get::<_, String>(0))
2725            .unwrap()
2726            .collect::<Result<_, _>>()
2727            .unwrap();
2728        assert_eq!(rows, vec!["new".to_string()]);
2729    }
2730
2731    // ── relink_all ─────────────────────────────────────────────────────
2732
2733    #[test]
2734    fn relink_all_resolves_forward_refs() {
2735        let conn = setup();
2736        // a links to b before b exists
2737        insert_node(&conn, "a", &link_doc(&["b"])).unwrap();
2738        insert_node(&conn, "b", &empty_doc()).unwrap();
2739        // a's outgoing link should be empty (forward ref dropped)
2740        let n_before: i64 = conn
2741            .query_row(
2742                "SELECT COUNT(*) FROM links WHERE source_id = 'a'",
2743                [],
2744                |r| r.get(0),
2745            )
2746            .unwrap();
2747        assert_eq!(n_before, 0);
2748
2749        let (nodes, links) = relink_all(&conn).unwrap();
2750        assert_eq!(nodes, 2);
2751        assert_eq!(links, 1);
2752        let rows: Vec<String> = conn
2753            .prepare("SELECT target_id FROM links WHERE source_id = 'a'")
2754            .unwrap()
2755            .query_map([], |r| r.get::<_, String>(0))
2756            .unwrap()
2757            .collect::<Result<_, _>>()
2758            .unwrap();
2759        assert_eq!(rows, vec!["b".to_string()]);
2760    }
2761
2762    #[test]
2763    fn relink_all_skips_corrupt_blob() {
2764        let conn = setup();
2765        insert_node(&conn, "good", &empty_doc()).unwrap();
2766        // Inject a corrupt blob bypassing insert_node.
2767        conn.execute(
2768            "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) VALUES ('bad', 'x', 'not-a-sexp', '0', '0')",
2769            [],
2770        )
2771        .unwrap();
2772        let (nodes, links) = relink_all(&conn).unwrap();
2773        assert_eq!(nodes, 2);
2774        assert_eq!(links, 0);
2775    }
2776
2777    #[test]
2778    fn relink_all_clears_stale_links_first() {
2779        let conn = setup();
2780        insert_node(&conn, "src", &empty_doc()).unwrap();
2781        insert_node(&conn, "tgt", &empty_doc()).unwrap();
2782        // Manually insert a stale link to a (still-existing) target.
2783        conn.execute(
2784            "INSERT INTO links (source_id, target_id, link_type) VALUES ('src', 'tgt', 'id')",
2785            [],
2786        )
2787        .unwrap();
2788        // No nodes reference anything in their blobs, so relink_all
2789        // should drop the stale link.
2790        let (_, links) = relink_all(&conn).unwrap();
2791        assert_eq!(links, 0);
2792        let n: i64 = conn
2793            .query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
2794            .unwrap();
2795        assert_eq!(n, 0);
2796    }
2797
2798    // ── Neighborhood ───────────────────────────────────────────────────
2799
2800    #[test]
2801    fn neighborhood_unknown_id_is_empty() {
2802        let conn = setup();
2803        let n = get_neighborhood(&conn, "nope").unwrap();
2804        assert!(n.outgoing.is_empty());
2805        assert!(n.incoming.is_empty());
2806    }
2807
2808    #[test]
2809    fn neighborhood_returns_outgoing_and_incoming() {
2810        let conn = setup();
2811        insert_node(&conn, "a", &empty_doc()).unwrap();
2812        insert_node(&conn, "b", &empty_doc()).unwrap();
2813        insert_node(&conn, "c", &empty_doc()).unwrap();
2814        // a -> b, c -> a
2815        relink_one(&conn, "a", &link_doc(&["b"])).unwrap();
2816        relink_one(&conn, "c", &link_doc(&["a"])).unwrap();
2817
2818        let n = get_neighborhood(&conn, "a").unwrap();
2819        assert_eq!(n.outgoing.len(), 1);
2820        assert_eq!(n.outgoing[0].0.0, "b");
2821        assert_eq!(n.outgoing[0].1, LinkType::Id);
2822        assert_eq!(n.incoming.len(), 1);
2823        assert_eq!(n.incoming[0].0.0, "c");
2824        assert_eq!(n.incoming[0].1, LinkType::Id);
2825    }
2826
2827    // ── list_all_nodes ─────────────────────────────────────────────────
2828
2829    #[test]
2830    fn list_all_nodes_zero_limit_empty() {
2831        let conn = setup();
2832        insert_node(&conn, "a", &sample_doc()).unwrap();
2833        let v = list_all_nodes(&conn, 0, 0).unwrap();
2834        assert!(v.is_empty());
2835    }
2836
2837    #[test]
2838    fn list_all_nodes_orders_by_updated_at_desc() {
2839        let conn = setup();
2840        insert_node(&conn, "first", &sample_doc()).unwrap();
2841        std::thread::sleep(std::time::Duration::from_millis(10));
2842        insert_node(&conn, "second", &sample_doc()).unwrap();
2843        std::thread::sleep(std::time::Duration::from_millis(10));
2844        insert_node(&conn, "third", &sample_doc()).unwrap();
2845
2846        let v = list_all_nodes(&conn, 10, 0).unwrap();
2847        let ids: Vec<String> = v.iter().map(|(n, _)| n.0.clone()).collect();
2848        assert_eq!(ids, vec!["third", "second", "first"]);
2849    }
2850
2851    #[test]
2852    fn list_all_nodes_paginates() {
2853        let conn = setup();
2854        for i in 0..5 {
2855            insert_node(&conn, &format!("n{i}"), &sample_doc()).unwrap();
2856            std::thread::sleep(std::time::Duration::from_millis(2));
2857        }
2858        let page1 = list_all_nodes(&conn, 2, 0).unwrap();
2859        let page2 = list_all_nodes(&conn, 2, 2).unwrap();
2860        assert_eq!(page1.len(), 2);
2861        assert_eq!(page2.len(), 2);
2862        // No overlap.
2863        for (id1, _) in &page1 {
2864            for (id2, _) in &page2 {
2865                assert_ne!(id1.0, id2.0);
2866            }
2867        }
2868    }
2869
2870    // ── Write-path relinks ────────────────────────────────────────────
2871
2872    #[test]
2873    fn write_path_relinks_on_insert() {
2874        let conn = setup();
2875        insert_node(&conn, "tgt", &empty_doc()).unwrap();
2876        insert_node(&conn, "src", &link_doc(&["tgt"])).unwrap();
2877        let n: i64 = conn
2878            .query_row(
2879                "SELECT COUNT(*) FROM links WHERE source_id = 'src' AND target_id = 'tgt'",
2880                [],
2881                |r| r.get(0),
2882            )
2883            .unwrap();
2884        assert_eq!(n, 1);
2885    }
2886
2887    #[test]
2888    fn write_path_relinks_on_update() {
2889        let conn = setup();
2890        insert_node(&conn, "tgt1", &empty_doc()).unwrap();
2891        insert_node(&conn, "tgt2", &empty_doc()).unwrap();
2892        insert_node(&conn, "src", &link_doc(&["tgt1"])).unwrap();
2893        update_node(&conn, "src", &link_doc(&["tgt2"])).unwrap();
2894        let rows: Vec<String> = conn
2895            .prepare("SELECT target_id FROM links WHERE source_id = 'src'")
2896            .unwrap()
2897            .query_map([], |r| r.get::<_, String>(0))
2898            .unwrap()
2899            .collect::<Result<_, _>>()
2900            .unwrap();
2901        assert_eq!(rows, vec!["tgt2".to_string()]);
2902    }
2903
2904    #[test]
2905    fn write_path_relinks_drop_forward_refs_silently() {
2906        let conn = setup();
2907        // Source linking to a not-yet-existing target.
2908        insert_node(&conn, "src", &link_doc(&["future"])).unwrap();
2909        let n: i64 = conn
2910            .query_row(
2911                "SELECT COUNT(*) FROM links WHERE source_id = 'src'",
2912                [],
2913                |r| r.get(0),
2914            )
2915            .unwrap();
2916        assert_eq!(n, 0);
2917        // After the target shows up, relink_all picks it up.
2918        insert_node(&conn, "future", &empty_doc()).unwrap();
2919        let (_, links) = relink_all(&conn).unwrap();
2920        assert_eq!(links, 1);
2921    }
2922
2923    // ── Embeddings: schema parity ─────────────────────────────────────
2924
2925    #[test]
2926    fn schema_embeddings_table_columns_and_pk() {
2927        let conn = setup();
2928        let cols: Vec<(String, String, i64, i64)> = conn
2929            .prepare("PRAGMA table_info(embeddings)")
2930            .unwrap()
2931            .query_map([], |r| {
2932                Ok((
2933                    r.get::<_, String>(1)?, // name
2934                    r.get::<_, String>(2)?, // type
2935                    r.get::<_, i64>(3)?,    // notnull
2936                    r.get::<_, i64>(5)?,    // pk
2937                ))
2938            })
2939            .unwrap()
2940            .collect::<Result<_, _>>()
2941            .unwrap();
2942        assert_eq!(
2943            cols,
2944            vec![
2945                ("node_id".into(), "TEXT".into(), 1, 1),
2946                ("model".into(), "TEXT".into(), 1, 2),
2947                ("embedding".into(), "BLOB".into(), 1, 0),
2948            ]
2949        );
2950    }
2951
2952    #[test]
2953    fn schema_embeddings_table_foreign_keys() {
2954        let conn = setup();
2955        let fks: Vec<(String, String, String, String)> = conn
2956            .prepare("PRAGMA foreign_key_list(embeddings)")
2957            .unwrap()
2958            .query_map([], |r| {
2959                Ok((
2960                    r.get::<_, String>(2)?, // table
2961                    r.get::<_, String>(3)?, // from
2962                    r.get::<_, String>(4)?, // to
2963                    r.get::<_, String>(6)?, // on_delete
2964                ))
2965            })
2966            .unwrap()
2967            .collect::<Result<_, _>>()
2968            .unwrap();
2969        assert_eq!(fks.len(), 1);
2970        let (table, from, to, on_delete) = &fks[0];
2971        assert_eq!(table, "nodes");
2972        assert_eq!(from, "node_id");
2973        assert_eq!(to, "id");
2974        assert_eq!(on_delete, "CASCADE");
2975    }
2976
2977    #[test]
2978    fn schema_embeddings_model_index_present() {
2979        let conn = setup();
2980        let n: i64 = conn
2981            .query_row(
2982                "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='embeddings_model_idx' AND tbl_name='embeddings'",
2983                [],
2984                |r| r.get(0),
2985            )
2986            .unwrap();
2987        assert_eq!(n, 1);
2988    }
2989
2990    #[test]
2991    fn schema_embeddings_version_unchanged() {
2992        // The embeddings feature must NOT bump the schema version on its
2993        // own; the stamped version tracks CURRENT_SCHEMA_VERSION.
2994        let conn = setup();
2995        let v: i64 = conn
2996            .query_row("PRAGMA user_version", [], |r| r.get(0))
2997            .unwrap();
2998        assert_eq!(v, i64::from(CURRENT_SCHEMA_VERSION));
2999    }
3000
3001    #[test]
3002    fn schema_embeddings_table_cascades_on_node_delete() {
3003        let conn = setup();
3004        insert_node(&conn, "n", &sample_doc()).unwrap();
3005        conn.execute(
3006            "INSERT INTO embeddings (node_id, model, embedding) VALUES ('n', 'm', X'00000000')",
3007            [],
3008        )
3009        .unwrap();
3010        delete_node(&conn, "n").unwrap();
3011        let n: i64 = conn
3012            .query_row(
3013                "SELECT COUNT(*) FROM embeddings WHERE node_id = 'n'",
3014                [],
3015                |r| r.get(0),
3016            )
3017            .unwrap();
3018        assert_eq!(n, 0, "FOREIGN KEY ON DELETE CASCADE must clear embeddings");
3019    }
3020
3021    // ── cosine_similarity ────────────────────────────────────────────
3022
3023    #[test]
3024    fn cosine_similarity_identical_vectors_is_one() {
3025        let v = vec![1.0_f32, 2.0, 3.0];
3026        let s = cosine_similarity(&v, &v);
3027        assert!((s - 1.0).abs() < 1e-6, "got {s}");
3028    }
3029
3030    #[test]
3031    fn cosine_similarity_orthogonal_vectors_is_zero() {
3032        let s = cosine_similarity(&[1.0_f32, 0.0], &[0.0, 1.0]);
3033        assert!(s.abs() < 1e-6, "got {s}");
3034    }
3035
3036    #[test]
3037    fn cosine_similarity_opposite_vectors_is_negative_one() {
3038        let s = cosine_similarity(&[1.0_f32, 0.0], &[-1.0, 0.0]);
3039        assert!((s + 1.0).abs() < 1e-6, "got {s}");
3040    }
3041
3042    #[test]
3043    fn cosine_similarity_length_mismatch_returns_zero() {
3044        let s = cosine_similarity(&[1.0_f32, 2.0], &[1.0, 2.0, 3.0]);
3045        assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3046    }
3047
3048    #[test]
3049    fn cosine_similarity_zero_norm_returns_zero() {
3050        let s = cosine_similarity(&[0.0_f32, 0.0, 0.0], &[1.0, 2.0, 3.0]);
3051        assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3052        let s = cosine_similarity(&[1.0_f32, 2.0], &[0.0, 0.0]);
3053        assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3054    }
3055
3056    #[test]
3057    fn cosine_similarity_empty_vectors_is_zero() {
3058        // Empty vectors have norm 0 -> 0.
3059        let s = cosine_similarity(&[], &[]);
3060        assert!(s.to_bits() == 0.0_f32.to_bits(), "got {s}");
3061    }
3062
3063    // ── reciprocal_rank_fusion ───────────────────────────────────────
3064
3065    #[test]
3066    fn rrf_empty_lists_yields_empty() {
3067        let out = reciprocal_rank_fusion(60, &[]);
3068        assert!(out.is_empty());
3069        let out = reciprocal_rank_fusion(60, &[Vec::<NodeId>::new(), Vec::<NodeId>::new()]);
3070        assert!(out.is_empty());
3071    }
3072
3073    #[test]
3074    fn rrf_single_list_preserves_order() {
3075        let l = vec![NodeId("a".into()), NodeId("b".into()), NodeId("c".into())];
3076        let out = reciprocal_rank_fusion(60, std::slice::from_ref(&l));
3077        assert_eq!(out, l);
3078    }
3079
3080    #[test]
3081    fn rrf_two_lists_combine_scores() {
3082        let l1 = vec![NodeId("a".into()), NodeId("b".into())];
3083        let l2 = vec![NodeId("b".into()), NodeId("a".into())];
3084        // Both nodes appear in both lists. With k=60:
3085        //   a: 1/61 + 1/62 = 0.0163934 + 0.0161290 = 0.0325224
3086        //   b: 1/61 + 1/62 = same
3087        // Tie-broken by ascending id -> a, b.
3088        let out = reciprocal_rank_fusion(60, &[l1, l2]);
3089        assert_eq!(out, vec![NodeId("a".into()), NodeId("b".into())]);
3090    }
3091
3092    #[test]
3093    fn rrf_node_only_in_one_list_ranks_lower() {
3094        // a in list 1 only; b in both lists.
3095        let l1 = vec![NodeId("a".into()), NodeId("b".into())];
3096        let l2 = vec![NodeId("b".into())];
3097        // a: 1/61 = 0.01639
3098        // b: 1/62 + 1/61 = 0.01613 + 0.01639 = 0.03252
3099        // -> b first, a second.
3100        let out = reciprocal_rank_fusion(60, &[l1, l2]);
3101        assert_eq!(out, vec![NodeId("b".into()), NodeId("a".into())]);
3102    }
3103
3104    #[test]
3105    fn rrf_ties_break_by_ascending_id() {
3106        let l1 = vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())];
3107        let l2 = vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())];
3108        // All three appear at identical ranks in both lists -> equal scores.
3109        // Tie-break by ascending id -> a, m, z (within each rank tier).
3110        // But ranks differ: z@1, a@2, m@3 in both. Scores:
3111        //   z: 2/61
3112        //   a: 2/62
3113        //   m: 2/63
3114        // Distinct, so order is z, a, m.
3115        let out = reciprocal_rank_fusion(60, &[l1, l2]);
3116        assert_eq!(
3117            out,
3118            vec![NodeId("z".into()), NodeId("a".into()), NodeId("m".into())]
3119        );
3120    }
3121
3122    #[test]
3123    fn rrf_truly_tied_scores_break_by_ascending_id() {
3124        // Force a real tie: same node at rank 1 in two lists has same score.
3125        // To engineer two distinct nodes with truly equal scores, give each
3126        // one the same rank in some list.
3127        let l1 = vec![NodeId("z".into())]; // z@1 -> 1/61
3128        let l2 = vec![NodeId("a".into())]; // a@1 -> 1/61
3129        let out = reciprocal_rank_fusion(60, &[l1, l2]);
3130        // Equal score -> ascending id -> a, z.
3131        assert_eq!(out, vec![NodeId("a".into()), NodeId("z".into())]);
3132    }
3133
3134    // ── try_load_vec_extension ───────────────────────────────────────
3135
3136    #[test]
3137    fn vec_extension_missing_path_returns_err() {
3138        let conn = setup();
3139        let err = try_load_vec_extension(&conn, "/nonexistent/path/to/sqlite-vec.dylib")
3140            .expect_err("loading a missing path must fail");
3141        assert!(matches!(err, VecExtensionError::LoadExtension(_)));
3142    }
3143
3144    #[test]
3145    fn vec_extension_failure_leaves_connection_usable() {
3146        let conn = setup();
3147        let _ = try_load_vec_extension(&conn, "/nonexistent/path/to/sqlite-vec.dylib");
3148        // The connection must remain usable for normal queries.
3149        insert_node(&conn, "after-fail", &sample_doc()).unwrap();
3150        let doc = get_node(&conn, "after-fail").unwrap().unwrap();
3151        assert_eq!(doc, sample_doc());
3152    }
3153
3154    // ── Embedding write path (precomputed vector) ───────────────────
3155
3156    fn count_embeddings(conn: &Connection, model: &str) -> i64 {
3157        conn.query_row(
3158            "SELECT COUNT(*) FROM embeddings WHERE model = ?1",
3159            params![model],
3160            |r| r.get(0),
3161        )
3162        .unwrap()
3163    }
3164
3165    /// Criterion: storage-takes-precomputed-embedding.
3166    /// Verifies `insert_node_with` writes one embeddings row when both a
3167    /// vector and a model are supplied, and skips the row when either is
3168    /// absent.
3169    #[test]
3170    fn storage_precomputed_embedding_inserts_one_row_on_insert() {
3171        let conn = setup();
3172        insert_node_with(
3173            &conn,
3174            "n",
3175            &sample_doc(),
3176            Some(vec![1.0, 2.0, 3.0]),
3177            Some("test-model"),
3178        )
3179        .unwrap();
3180        assert_eq!(count_embeddings(&conn, "test-model"), 1);
3181        let blob: Vec<u8> = conn
3182            .query_row(
3183                "SELECT embedding FROM embeddings WHERE node_id = 'n' AND model = 'test-model'",
3184                [],
3185                |r| r.get(0),
3186            )
3187            .unwrap();
3188        let decoded = embedding::decode_embedding(&blob).unwrap();
3189        assert_eq!(decoded, vec![1.0_f32, 2.0, 3.0]);
3190    }
3191
3192    #[test]
3193    fn storage_precomputed_embedding_replaces_on_update() {
3194        let conn = setup();
3195        insert_node_with(
3196            &conn,
3197            "n",
3198            &sample_doc(),
3199            Some(vec![1.0, 0.0]),
3200            Some("test-model"),
3201        )
3202        .unwrap();
3203        let mut doc = sample_doc();
3204        doc.blocks.push(Block::Paragraph {
3205            inlines: vec![Inline::Plain("more".into())],
3206        });
3207        update_node_with(&conn, "n", &doc, Some(vec![0.0, 1.0]), Some("test-model")).unwrap();
3208        assert_eq!(count_embeddings(&conn, "test-model"), 1);
3209        let blob: Vec<u8> = conn
3210            .query_row(
3211                "SELECT embedding FROM embeddings WHERE node_id = 'n'",
3212                [],
3213                |r| r.get(0),
3214            )
3215            .unwrap();
3216        assert_eq!(
3217            embedding::decode_embedding(&blob).unwrap(),
3218            vec![0.0_f32, 1.0]
3219        );
3220    }
3221
3222    #[test]
3223    fn storage_precomputed_embedding_skipped_without_vector() {
3224        let conn = setup();
3225        insert_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3226        update_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3227        assert_eq!(
3228            conn.query_row::<i64, _, _>("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3229                .unwrap(),
3230            0
3231        );
3232    }
3233
3234    #[test]
3235    fn storage_precomputed_embedding_skipped_without_model() {
3236        // A vector with no model is treated as "no embedding" — both must
3237        // be Some for a row to be written.
3238        let conn = setup();
3239        insert_node_with(&conn, "n", &sample_doc(), Some(vec![1.0]), None).unwrap();
3240        assert_eq!(
3241            conn.query_row::<i64, _, _>("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3242                .unwrap(),
3243            0
3244        );
3245    }
3246
3247    #[test]
3248    fn storage_precomputed_embedding_keys_by_node_and_model() {
3249        let conn = setup();
3250        insert_node_with(
3251            &conn,
3252            "n",
3253            &sample_doc(),
3254            Some(vec![1.0, 0.0]),
3255            Some("model-a"),
3256        )
3257        .unwrap();
3258        update_node_with(
3259            &conn,
3260            "n",
3261            &sample_doc(),
3262            Some(vec![0.0, 1.0]),
3263            Some("model-b"),
3264        )
3265        .unwrap();
3266        assert_eq!(count_embeddings(&conn, "model-a"), 1);
3267        assert_eq!(count_embeddings(&conn, "model-b"), 1);
3268    }
3269
3270    // ── search_hybrid ────────────────────────────────────────────────
3271
3272    #[test]
3273    fn search_hybrid_no_query_embedding_matches_search_fts() {
3274        let conn = setup();
3275        let doc1 = Document {
3276            blocks: vec![Block::Heading {
3277                level: 1,
3278                title: Title("Rust".into()),
3279                tags: vec![],
3280                children: vec![Block::Paragraph {
3281                    inlines: vec![Inline::Plain("systems language".into())],
3282                }],
3283            }],
3284        };
3285        let doc2 = Document {
3286            blocks: vec![Block::Heading {
3287                level: 1,
3288                title: Title("Python".into()),
3289                tags: vec![],
3290                children: vec![Block::Paragraph {
3291                    inlines: vec![Inline::Plain("scripting language".into())],
3292                }],
3293            }],
3294        };
3295        insert_node(&conn, "a", &doc1).unwrap();
3296        insert_node(&conn, "b", &doc2).unwrap();
3297        let fts: Vec<NodeId> = search_fts(&conn, "language")
3298            .unwrap()
3299            .into_iter()
3300            .map(NodeId)
3301            .collect();
3302        let hybrid = search_hybrid(&conn, "language", None).unwrap();
3303        assert_eq!(hybrid, fts);
3304    }
3305
3306    #[test]
3307    fn search_hybrid_empty_query_yields_empty() {
3308        let conn = setup();
3309        insert_node(&conn, "a", &sample_doc()).unwrap();
3310        // No query embedding.
3311        assert!(search_hybrid(&conn, "", None).unwrap().is_empty());
3312        // With a query embedding and whitespace-only query AND empty
3313        // keyword list -> still empty.
3314        assert!(
3315            search_hybrid(&conn, "   ", Some((vec![1.0], "stub")))
3316                .unwrap()
3317                .is_empty()
3318        );
3319    }
3320
3321    #[test]
3322    fn search_hybrid_with_query_embedding_combines_keyword_and_vector_rankings() {
3323        let conn = setup();
3324        insert_node(
3325            &conn,
3326            "alpha",
3327            &Document {
3328                blocks: vec![Block::Heading {
3329                    level: 1,
3330                    title: Title("alpha note".into()),
3331                    tags: vec![],
3332                    children: vec![Block::Paragraph {
3333                        inlines: vec![Inline::Plain("the quick brown fox".into())],
3334                    }],
3335                }],
3336            },
3337        )
3338        .unwrap();
3339        insert_node(
3340            &conn,
3341            "beta",
3342            &Document {
3343                blocks: vec![Block::Heading {
3344                    level: 1,
3345                    title: Title("beta note".into()),
3346                    tags: vec![],
3347                    children: vec![Block::Paragraph {
3348                        inlines: vec![Inline::Plain("a different idea entirely".into())],
3349                    }],
3350                }],
3351            },
3352        )
3353        .unwrap();
3354        let alpha_vec: Vec<f32> = vec![1.0, 0.0];
3355        let beta_vec: Vec<f32> = vec![0.0, 1.0];
3356        for (id, v) in [("alpha", &alpha_vec), ("beta", &beta_vec)] {
3357            conn.execute(
3358                "INSERT INTO embeddings (node_id, model, embedding) VALUES (?1, 'hybrid-model', ?2)",
3359                params![id, embedding::encode_embedding(v)],
3360            )
3361            .unwrap();
3362        }
3363        // Caller-supplied query vector aligned with beta -> vector
3364        // ranking puts beta first.
3365        let out =
3366            search_hybrid(&conn, "alpha", Some((vec![0.0_f32, 1.0], "hybrid-model"))).unwrap();
3367        // FTS for "alpha" -> [alpha]; vector -> [beta, alpha].
3368        // RRF k=60:
3369        //   alpha: 1/61 + 1/62 ~ 0.0325
3370        //   beta:  1/61      ~ 0.0164
3371        // -> alpha first, beta second.
3372        assert_eq!(out, vec![NodeId("alpha".into()), NodeId("beta".into())]);
3373    }
3374
3375    #[test]
3376    fn search_hybrid_with_query_embedding_returns_vector_only_when_fts_empty() {
3377        let conn = setup();
3378        insert_node(&conn, "a", &sample_doc()).unwrap();
3379        conn.execute(
3380            "INSERT INTO embeddings (node_id, model, embedding) VALUES ('a', 'm', ?1)",
3381            params![embedding::encode_embedding(&[1.0_f32])],
3382        )
3383        .unwrap();
3384        // Query "nomatch" — FTS returns nothing, but query is non-whitespace,
3385        // so vector ranking still runs.
3386        let out = search_hybrid(&conn, "nomatch", Some((vec![1.0_f32], "m"))).unwrap();
3387        assert_eq!(out, vec![NodeId("a".into())]);
3388    }
3389
3390    // ── embedding_disabled (default behaviour with no precomputed embedding) ──
3391
3392    #[test]
3393    fn embedding_disabled_writes_succeed() {
3394        let conn = setup();
3395        insert_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3396        update_node_with(&conn, "n", &sample_doc(), None, None).unwrap();
3397        delete_node(&conn, "n").unwrap();
3398        assert!(get_node(&conn, "n").unwrap().is_none());
3399    }
3400
3401    #[test]
3402    fn embedding_disabled_keeps_embeddings_table_empty() {
3403        let conn = setup();
3404        insert_node(&conn, "a", &sample_doc()).unwrap();
3405        insert_node(&conn, "b", &sample_doc()).unwrap();
3406        let n: i64 = conn
3407            .query_row("SELECT COUNT(*) FROM embeddings", [], |r| r.get(0))
3408            .unwrap();
3409        assert_eq!(n, 0);
3410    }
3411
3412    #[test]
3413    fn embedding_disabled_search_hybrid_degrades_to_search_fts() {
3414        let conn = setup();
3415        let doc = Document {
3416            blocks: vec![Block::Heading {
3417                level: 1,
3418                title: Title("hello".into()),
3419                tags: vec![],
3420                children: vec![Block::Paragraph {
3421                    inlines: vec![Inline::Plain("world".into())],
3422                }],
3423            }],
3424        };
3425        insert_node(&conn, "a", &doc).unwrap();
3426        let fts: Vec<NodeId> = search_fts(&conn, "hello")
3427            .unwrap()
3428            .into_iter()
3429            .map(NodeId)
3430            .collect();
3431        let hybrid = search_hybrid(&conn, "hello", None).unwrap();
3432        assert_eq!(hybrid, fts);
3433    }
3434
3435    // ── Unified links table: id-links + name-links coexist ────────────
3436
3437    /// Build a document that has a `#+name:` slug and a body paragraph.
3438    fn named_doc(slug: &str, body: &str) -> Document {
3439        Document {
3440            blocks: vec![
3441                Block::Keyword {
3442                    name: "name".into(),
3443                    value: format!(" {slug}"),
3444                },
3445                Block::Paragraph {
3446                    inlines: vec![Inline::Plain(body.into())],
3447                },
3448            ],
3449        }
3450    }
3451
3452    /// Build a document that references `[[slug]]` bracket names.
3453    fn referencing_doc(slugs: &[&str]) -> Document {
3454        let inlines: Vec<Inline> = slugs
3455            .iter()
3456            .map(|s| Inline::Link {
3457                target: (*s).to_string(),
3458                description: None,
3459            })
3460            .collect();
3461        Document {
3462            blocks: vec![Block::Paragraph { inlines }],
3463        }
3464    }
3465
3466    fn count_links_total(conn: &Connection) -> i64 {
3467        conn.query_row("SELECT COUNT(*) FROM links", [], |r| r.get(0))
3468            .unwrap()
3469    }
3470
3471    /// Fetch a single name-link row from the unified table.
3472    fn fetch_name_link(conn: &Connection, src_id: &str, slug: &str) -> Option<LinkRow> {
3473        conn.query_row(
3474            "SELECT source_id, link_type, target_id, target_slug FROM links \
3475             WHERE source_id = ?1 AND link_type = 'name' AND target_slug = ?2",
3476            params![src_id, slug],
3477            |r| {
3478                Ok(LinkRow {
3479                    source_id: r.get(0)?,
3480                    link_type: r.get(1)?,
3481                    target_id: r.get(2)?,
3482                    target_slug: r.get(3)?,
3483                })
3484            },
3485        )
3486        .optional()
3487        .unwrap()
3488    }
3489
3490    /// Sentinel: the consolidated graph passes id+name semantics through
3491    /// the unified table. Aggregated under one criterion so the contract
3492    /// can pin a single filter per behavior.
3493    #[test]
3494    fn links_table_unified_schema() {
3495        let conn = setup();
3496        // Single physical link table, no name_links shadow.
3497        let n_links: i64 = conn
3498            .query_row(
3499                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='links'",
3500                [],
3501                |r| r.get(0),
3502            )
3503            .unwrap();
3504        assert_eq!(n_links, 1);
3505        let n_name_links: i64 = conn
3506            .query_row(
3507                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
3508                [],
3509                |r| r.get(0),
3510            )
3511            .unwrap();
3512        assert_eq!(n_name_links, 0, "name_links table must not exist");
3513
3514        // No view named name_links either.
3515        let n_view: i64 = conn
3516            .query_row(
3517                "SELECT COUNT(*) FROM sqlite_master WHERE type='view' AND name='name_links'",
3518                [],
3519                |r| r.get(0),
3520            )
3521            .unwrap();
3522        assert_eq!(n_view, 0, "name_links view must not exist");
3523
3524        // Columns and PK shape.
3525        let cols: Vec<(String, String, i64, i64)> = conn
3526            .prepare("PRAGMA table_info(links)")
3527            .unwrap()
3528            .query_map([], |r| {
3529                Ok((
3530                    r.get::<_, String>(1)?,
3531                    r.get::<_, String>(2)?,
3532                    r.get::<_, i64>(3)?,
3533                    r.get::<_, i64>(5)?,
3534                ))
3535            })
3536            .unwrap()
3537            .collect::<Result<_, _>>()
3538            .unwrap();
3539        assert_eq!(
3540            cols,
3541            vec![
3542                ("source_id".into(), "TEXT".into(), 1, 1),
3543                ("link_type".into(), "TEXT".into(), 1, 2),
3544                ("target_id".into(), "TEXT".into(), 0, 3),
3545                ("target_slug".into(), "TEXT".into(), 0, 4),
3546            ]
3547        );
3548
3549        // CHECK enforces the per-link_type column contract.
3550        insert_node(&conn, "src", &empty_doc()).unwrap();
3551        for sql in [
3552            // id-link with NULL target_id
3553            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3554             VALUES ('src', 'id', NULL, NULL)",
3555            // id-link with target_slug
3556            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3557             VALUES ('src', 'id', 'src', 'foo')",
3558            // name-link with NULL target_slug
3559            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3560             VALUES ('src', 'name', NULL, NULL)",
3561            // unknown link_type
3562            "INSERT INTO links (source_id, link_type, target_id, target_slug) \
3563             VALUES ('src', 'other', 'src', NULL)",
3564        ] {
3565            assert!(
3566                conn.execute(sql, []).is_err(),
3567                "expected CHECK rejection: {sql}"
3568            );
3569        }
3570
3571        // Real writes coexist in the same table under different link_types.
3572        insert_node(&conn, "named", &named_doc("alpha", "x")).unwrap();
3573        let mut doc_with_both = referencing_doc(&["alpha"]);
3574        doc_with_both.blocks.push(Block::Paragraph {
3575            inlines: vec![Inline::Link {
3576                target: "id:src".into(),
3577                description: None,
3578            }],
3579        });
3580        insert_node(&conn, "mixed", &doc_with_both).unwrap();
3581        let rows: Vec<(String, String, Option<String>, Option<String>)> = conn
3582            .prepare(
3583                "SELECT source_id, link_type, target_id, target_slug FROM links \
3584                 WHERE source_id = 'mixed' ORDER BY link_type",
3585            )
3586            .unwrap()
3587            .query_map([], |r| {
3588                Ok((
3589                    r.get::<_, String>(0)?,
3590                    r.get::<_, String>(1)?,
3591                    r.get::<_, Option<String>>(2)?,
3592                    r.get::<_, Option<String>>(3)?,
3593                ))
3594            })
3595            .unwrap()
3596            .collect::<Result<_, _>>()
3597            .unwrap();
3598        assert_eq!(
3599            rows,
3600            vec![
3601                ("mixed".into(), "id".into(), Some("src".into()), None),
3602                (
3603                    "mixed".into(),
3604                    "name".into(),
3605                    Some("named".into()),
3606                    Some("alpha".into())
3607                ),
3608            ]
3609        );
3610    }
3611
3612    /// Aggregated id-link semantics under the unified schema.
3613    #[test]
3614    fn id_link_semantics_preserved() {
3615        // Extraction: `[[id:UUID]]` extraction matches pre-consolidation.
3616        let doc = link_doc(&["aaa", "bbb", "aaa"]);
3617        let ids: Vec<String> = extract_links(&doc)
3618            .into_iter()
3619            .filter_map(|l| match l {
3620                LinkRef::Id(s) => Some(s),
3621                LinkRef::Name(_) => None,
3622            })
3623            .collect();
3624        assert_eq!(ids, vec!["aaa".to_string(), "bbb".to_string()]);
3625
3626        // Storage: insert + neighborhood + relinker behavior on a small graph.
3627        let conn = setup();
3628        insert_node(&conn, "tgt", &empty_doc()).unwrap();
3629        insert_node(&conn, "src", &link_doc(&["tgt"])).unwrap();
3630        let row: (Option<String>, String, Option<String>) = conn
3631            .query_row(
3632                "SELECT target_id, link_type, target_slug FROM links WHERE source_id = 'src'",
3633                [],
3634                |r| {
3635                    Ok((
3636                        r.get::<_, Option<String>>(0)?,
3637                        r.get::<_, String>(1)?,
3638                        r.get::<_, Option<String>>(2)?,
3639                    ))
3640                },
3641            )
3642            .unwrap();
3643        assert_eq!(row, (Some("tgt".into()), "id".into(), None));
3644
3645        // Forward refs to nonexistent targets are silently dropped.
3646        insert_node(&conn, "fwd", &link_doc(&["missing-tgt"])).unwrap();
3647        let n: i64 = conn
3648            .query_row(
3649                "SELECT COUNT(*) FROM links WHERE source_id = 'fwd'",
3650                [],
3651                |r| r.get(0),
3652            )
3653            .unwrap();
3654        assert_eq!(n, 0);
3655
3656        // Neighborhood projects (NodeId, link_type).
3657        let n = get_neighborhood(&conn, "src").unwrap();
3658        assert_eq!(n.outgoing, vec![(NodeId("tgt".into()), LinkType::Id)]);
3659        let n = get_neighborhood(&conn, "tgt").unwrap();
3660        assert_eq!(n.incoming, vec![(NodeId("src".into()), LinkType::Id)]);
3661
3662        // Update replaces outgoing.
3663        insert_node(&conn, "tgt2", &empty_doc()).unwrap();
3664        update_node(&conn, "src", &link_doc(&["tgt2"])).unwrap();
3665        let n = get_neighborhood(&conn, "src").unwrap();
3666        assert_eq!(n.outgoing, vec![(NodeId("tgt2".into()), LinkType::Id)]);
3667
3668        // relink_all picks up forward references after the target appears.
3669        insert_node(&conn, "future-src", &link_doc(&["future-tgt"])).unwrap();
3670        insert_node(&conn, "future-tgt", &empty_doc()).unwrap();
3671        let (_, links) = relink_all(&conn).unwrap();
3672        assert!(links >= 1);
3673        let row: Option<String> = conn
3674            .query_row(
3675                "SELECT target_id FROM links \
3676                 WHERE source_id = 'future-src' AND link_type = 'id'",
3677                [],
3678                |r| r.get(0),
3679            )
3680            .optional()
3681            .unwrap()
3682            .flatten();
3683        assert_eq!(row.as_deref(), Some("future-tgt"));
3684
3685        // Id-link rows pointing at a deleted target are removed, NOT
3686        // demoted to broken (only name-link rows demote).
3687        delete_node(&conn, "future-tgt").unwrap();
3688        let n: i64 = conn
3689            .query_row(
3690                "SELECT COUNT(*) FROM links \
3691                 WHERE source_id = 'future-src' AND link_type = 'id'",
3692                [],
3693                |r| r.get(0),
3694            )
3695            .unwrap();
3696        assert_eq!(n, 0);
3697    }
3698
3699    /// Name-link broken + reverse-resolve behavior under the unified
3700    /// schema. Aggregated under one criterion.
3701    #[test]
3702    fn name_link_broken_and_reverse_resolve_preserved() {
3703        let conn = setup();
3704
3705        // Broken on write when no slug match exists.
3706        insert_node(&conn, "src1", &referencing_doc(&["future"])).unwrap();
3707        let row = fetch_name_link(&conn, "src1", "future").unwrap();
3708        assert_eq!(row.link_type, LinkType::Name);
3709        assert_eq!(row.target_slug.as_deref(), Some("future"));
3710        assert!(row.target_id.is_none());
3711
3712        // Resolved on write when a matching slug already exists.
3713        insert_node(&conn, "named", &named_doc("alpha", "I am alpha")).unwrap();
3714        insert_node(&conn, "src2", &referencing_doc(&["alpha"])).unwrap();
3715        let row = fetch_name_link(&conn, "src2", "alpha").unwrap();
3716        assert_eq!(row.target_id.as_deref(), Some("named"));
3717
3718        // Back-resolve when the matching target is created later.
3719        insert_node(&conn, "src3", &referencing_doc(&["beta"])).unwrap();
3720        assert!(
3721            fetch_name_link(&conn, "src3", "beta")
3722                .unwrap()
3723                .target_id
3724                .is_none()
3725        );
3726        insert_node(&conn, "beta-target", &named_doc("beta", "x")).unwrap();
3727        let row = fetch_name_link(&conn, "src3", "beta").unwrap();
3728        assert_eq!(row.target_id.as_deref(), Some("beta-target"));
3729
3730        // Back-resolve when an existing target is updated to carry the slug.
3731        insert_node(&conn, "src4", &referencing_doc(&["gamma"])).unwrap();
3732        insert_node(
3733            &conn,
3734            "gamma-target",
3735            &Document {
3736                blocks: vec![Block::Paragraph {
3737                    inlines: vec![Inline::Plain("unnamed".into())],
3738                }],
3739            },
3740        )
3741        .unwrap();
3742        assert!(
3743            fetch_name_link(&conn, "src4", "gamma")
3744                .unwrap()
3745                .target_id
3746                .is_none()
3747        );
3748        update_node(&conn, "gamma-target", &named_doc("gamma", "x")).unwrap();
3749        let row = fetch_name_link(&conn, "src4", "gamma").unwrap();
3750        assert_eq!(row.target_id.as_deref(), Some("gamma-target"));
3751
3752        // Update replaces prior outgoing name-link rows.
3753        update_node(&conn, "src2", &referencing_doc(&["beta"])).unwrap();
3754        assert!(fetch_name_link(&conn, "src2", "alpha").is_none());
3755        let row = fetch_name_link(&conn, "src2", "beta").unwrap();
3756        assert_eq!(row.target_id.as_deref(), Some("beta-target"));
3757
3758        // list_broken_links surfaces only unresolved name-link rows.
3759        insert_node(&conn, "src5", &referencing_doc(&["missing-1", "missing-2"])).unwrap();
3760        let broken = list_broken_links(&conn).unwrap();
3761        let slugs: Vec<&str> = broken
3762            .iter()
3763            .map(|r| r.target_slug.as_deref().unwrap_or(""))
3764            .collect();
3765        assert!(slugs.contains(&"missing-1"));
3766        assert!(slugs.contains(&"missing-2"));
3767        for row in &broken {
3768            assert_eq!(row.link_type, LinkType::Name);
3769            assert!(row.target_id.is_none());
3770        }
3771    }
3772
3773    /// Demote-on-delete and demote-on-slug-change semantics under the
3774    /// unified schema.
3775    #[test]
3776    fn name_link_demote_preserved() {
3777        let conn = setup();
3778        insert_node(&conn, "target", &named_doc("alpha", "x")).unwrap();
3779        insert_node(&conn, "source", &referencing_doc(&["alpha"])).unwrap();
3780        assert_eq!(
3781            fetch_name_link(&conn, "source", "alpha")
3782                .unwrap()
3783                .target_id
3784                .as_deref(),
3785            Some("target")
3786        );
3787
3788        // Slug change demotes resolved rows back to broken.
3789        update_node(&conn, "target", &named_doc("renamed", "x")).unwrap();
3790        let row = fetch_name_link(&conn, "source", "alpha").unwrap();
3791        assert!(row.target_id.is_none(), "slug change must demote to broken");
3792        assert_eq!(row.target_slug.as_deref(), Some("alpha"));
3793
3794        // Restore + delete: the row resolves back, then the delete
3795        // demotes it once more via the ON DELETE SET NULL FK.
3796        update_node(&conn, "target", &named_doc("alpha", "x")).unwrap();
3797        assert_eq!(
3798            fetch_name_link(&conn, "source", "alpha")
3799                .unwrap()
3800                .target_id
3801                .as_deref(),
3802            Some("target")
3803        );
3804        delete_node(&conn, "target").unwrap();
3805        let row = fetch_name_link(&conn, "source", "alpha").unwrap();
3806        assert!(row.target_id.is_none(), "delete must demote to broken");
3807        assert_eq!(row.target_slug.as_deref(), Some("alpha"));
3808
3809        // Source delete cascades the entire source's outgoing rows.
3810        delete_node(&conn, "source").unwrap();
3811        assert_eq!(count_links_total(&conn), 0);
3812    }
3813
3814    /// Migration from the pre-consolidation split shape (kb-link-index-v0).
3815    #[test]
3816    fn migration_from_split_tables() {
3817        // Build a fresh database, then *replace* the unified schema
3818        // with the legacy split shape and populate it with rows of all
3819        // three shapes (id-link, resolved name-link, broken name-link).
3820        let conn = Connection::open_in_memory().unwrap();
3821        conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
3822        conn.execute(NODES_TABLE, []).unwrap();
3823        conn.execute(NODE_TAGS_TABLE, []).unwrap();
3824        // Legacy split shape (verbatim from kb-link-index-v0).
3825        conn.execute(
3826            "CREATE TABLE links ( \
3827               source_id TEXT NOT NULL, \
3828               target_id TEXT NOT NULL, \
3829               link_type TEXT NOT NULL, \
3830               PRIMARY KEY (source_id, target_id, link_type), \
3831               FOREIGN KEY (source_id) REFERENCES nodes(id) ON DELETE CASCADE, \
3832               FOREIGN KEY (target_id) REFERENCES nodes(id) ON DELETE CASCADE)",
3833            [],
3834        )
3835        .unwrap();
3836        conn.execute(
3837            "CREATE TABLE name_links ( \
3838               src_id   TEXT NOT NULL, \
3839               dst_slug TEXT NOT NULL, \
3840               dst_id   TEXT, \
3841               PRIMARY KEY (src_id, dst_slug), \
3842               FOREIGN KEY (src_id) REFERENCES nodes(id) ON DELETE CASCADE)",
3843            [],
3844        )
3845        .unwrap();
3846        // Insert nodes.
3847        for nid in ["a", "b", "c", "d"] {
3848            conn.execute(
3849                "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) \
3850                 VALUES (?1, 't', ?2, '0', '0')",
3851                params![nid, sexp::encode_document(&Document { blocks: vec![] })],
3852            )
3853            .unwrap();
3854        }
3855        // Legacy id-link row.
3856        conn.execute(
3857            "INSERT INTO links (source_id, target_id, link_type) VALUES ('a', 'b', 'id')",
3858            [],
3859        )
3860        .unwrap();
3861        // Legacy resolved name-link row.
3862        conn.execute(
3863            "INSERT INTO name_links (src_id, dst_slug, dst_id) VALUES ('c', 'beta', 'b')",
3864            [],
3865        )
3866        .unwrap();
3867        // Legacy broken name-link row.
3868        conn.execute(
3869            "INSERT INTO name_links (src_id, dst_slug, dst_id) VALUES ('d', 'ghost', NULL)",
3870            [],
3871        )
3872        .unwrap();
3873
3874        // Run the migration via init_db (the supported entry point).
3875        init_db(&conn).unwrap();
3876
3877        // The legacy tables are gone; only the unified `links` table remains.
3878        let n_name_links: i64 = conn
3879            .query_row(
3880                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='name_links'",
3881                [],
3882                |r| r.get(0),
3883            )
3884            .unwrap();
3885        assert_eq!(n_name_links, 0);
3886
3887        // All three rows survive in the unified shape.
3888        let mut rows: Vec<(String, String, Option<String>, Option<String>)> = conn
3889            .prepare(
3890                "SELECT source_id, link_type, target_id, target_slug FROM links \
3891                 ORDER BY source_id",
3892            )
3893            .unwrap()
3894            .query_map([], |r| {
3895                Ok((
3896                    r.get::<_, String>(0)?,
3897                    r.get::<_, String>(1)?,
3898                    r.get::<_, Option<String>>(2)?,
3899                    r.get::<_, Option<String>>(3)?,
3900                ))
3901            })
3902            .unwrap()
3903            .collect::<Result<_, _>>()
3904            .unwrap();
3905        rows.sort();
3906        assert_eq!(
3907            rows,
3908            vec![
3909                ("a".into(), "id".into(), Some("b".into()), None),
3910                (
3911                    "c".into(),
3912                    "name".into(),
3913                    Some("b".into()),
3914                    Some("beta".into())
3915                ),
3916                ("d".into(), "name".into(), None, Some("ghost".into())),
3917            ]
3918        );
3919
3920        // Migration is idempotent — a second init_db is a no-op.
3921        init_db(&conn).unwrap();
3922        let n: i64 = count_links_total(&conn);
3923        assert_eq!(n, 3);
3924    }
3925
3926    #[test]
3927    fn link_parser_extracts_bracket_name_refs() {
3928        let doc = Document {
3929            blocks: vec![Block::Paragraph {
3930                inlines: vec![
3931                    Inline::Plain("see ".into()),
3932                    Inline::Link {
3933                        target: "alpha".into(),
3934                        description: None,
3935                    },
3936                    Inline::Plain(" and ".into()),
3937                    Inline::Link {
3938                        target: "beta".into(),
3939                        description: None,
3940                    },
3941                ],
3942            }],
3943        };
3944        let slugs: Vec<String> = extract_links(&doc)
3945            .into_iter()
3946            .filter_map(|l| match l {
3947                LinkRef::Name(s) => Some(s),
3948                LinkRef::Id(_) => None,
3949            })
3950            .collect();
3951        assert_eq!(slugs, vec!["alpha".to_string(), "beta".to_string()]);
3952    }
3953
3954    #[test]
3955    fn link_parser_extracts_dedupes_preserving_first_seen_order() {
3956        let doc = referencing_doc(&["alpha", "beta", "alpha", "gamma"]);
3957        let slugs: Vec<String> = extract_links(&doc)
3958            .into_iter()
3959            .filter_map(|l| match l {
3960                LinkRef::Name(s) => Some(s),
3961                LinkRef::Id(_) => None,
3962            })
3963            .collect();
3964        assert_eq!(slugs, vec!["alpha", "beta", "gamma"]);
3965    }
3966
3967    #[test]
3968    fn link_parser_extracts_walks_nested_inline_positions() {
3969        let doc = Document {
3970            blocks: vec![Block::Paragraph {
3971                inlines: vec![Inline::Bold(vec![Inline::Italic(vec![Inline::Link {
3972                    target: "nested".into(),
3973                    description: None,
3974                }])])],
3975            }],
3976        };
3977        let slugs: Vec<String> = extract_links(&doc)
3978            .into_iter()
3979            .filter_map(|l| match l {
3980                LinkRef::Name(s) => Some(s),
3981                LinkRef::Id(_) => None,
3982            })
3983            .collect();
3984        assert_eq!(slugs, vec!["nested".to_string()]);
3985    }
3986
3987    #[test]
3988    fn link_parser_extracts_ignores_scheme_links() {
3989        let doc = Document {
3990            blocks: vec![Block::Paragraph {
3991                inlines: vec![
3992                    Inline::Link {
3993                        target: "id:abc-123".into(),
3994                        description: None,
3995                    },
3996                    Inline::Link {
3997                        target: "https://example.com".into(),
3998                        description: Some("ex".into()),
3999                    },
4000                    Inline::Link {
4001                        target: "keep".into(),
4002                        description: None,
4003                    },
4004                    Inline::Link {
4005                        target: "alpha".into(),
4006                        description: Some("Alpha".into()),
4007                    },
4008                ],
4009            }],
4010        };
4011        let slugs: Vec<String> = extract_links(&doc)
4012            .into_iter()
4013            .filter_map(|l| match l {
4014                LinkRef::Name(s) => Some(s),
4015                LinkRef::Id(_) => None,
4016            })
4017            .collect();
4018        assert_eq!(slugs, vec!["keep".to_string()]);
4019    }
4020
4021    #[test]
4022    fn parser_respects_literal_regions_for_bracket_refs() {
4023        let doc = Document {
4024            blocks: vec![
4025                Block::Paragraph {
4026                    inlines: vec![
4027                        Inline::Plain("Use ".into()),
4028                        Inline::Verbatim("[[verbatim-target]]".into()),
4029                        Inline::Plain(" for verbatim, ".into()),
4030                        Inline::InlineCode("[[code-target]]".into()),
4031                        Inline::Plain(" for inline code.".into()),
4032                    ],
4033                },
4034                Block::SrcBlock {
4035                    language: "org".into(),
4036                    content: "Example: [[src-block-target]]\n".into(),
4037                },
4038                Block::ExampleBlock {
4039                    content: "Example: [[example-block-target]]\n".into(),
4040                },
4041                Block::Paragraph {
4042                    inlines: vec![Inline::Link {
4043                        target: "real-target".into(),
4044                        description: None,
4045                    }],
4046                },
4047            ],
4048        };
4049        let slugs: Vec<String> = extract_links(&doc)
4050            .into_iter()
4051            .filter_map(|l| match l {
4052                LinkRef::Name(s) => Some(s),
4053                LinkRef::Id(_) => None,
4054            })
4055            .collect();
4056        assert_eq!(slugs, vec!["real-target".to_string()]);
4057    }
4058
4059    #[test]
4060    fn parser_respects_literal_regions_round_trip_via_org_parser() {
4061        let org = "Use =[[verbatim-target]]= for verbatim, \
4062                   ~[[code-target]]~ for inline code.\n\
4063                   #+begin_src org\n\
4064                   Example: [[src-block-target]]\n\
4065                   #+end_src\n\
4066                   #+begin_example\n\
4067                   Example: [[example-block-target]]\n\
4068                   #+end_example\n\
4069                   [[real-target]]\n";
4070        let doc = crate::parser::parse_document(org).unwrap();
4071        let slugs: Vec<String> = extract_links(&doc)
4072            .into_iter()
4073            .filter_map(|l| match l {
4074                LinkRef::Name(s) => Some(s),
4075                LinkRef::Id(_) => None,
4076            })
4077            .collect();
4078        assert_eq!(slugs, vec!["real-target".to_string()]);
4079    }
4080
4081    /// The single body walker emits both link types from one pass.
4082    #[test]
4083    fn unified_walker_emits_both_link_types_in_one_pass() {
4084        let doc = Document {
4085            blocks: vec![Block::Paragraph {
4086                inlines: vec![
4087                    Inline::Link {
4088                        target: "id:abc".into(),
4089                        description: None,
4090                    },
4091                    Inline::Link {
4092                        target: "alpha".into(),
4093                        description: None,
4094                    },
4095                    Inline::Link {
4096                        target: "id:def".into(),
4097                        description: None,
4098                    },
4099                    Inline::Link {
4100                        target: "beta".into(),
4101                        description: None,
4102                    },
4103                ],
4104            }],
4105        };
4106        let refs = extract_links(&doc);
4107        assert_eq!(
4108            refs,
4109            vec![
4110                LinkRef::Id("abc".into()),
4111                LinkRef::Name("alpha".into()),
4112                LinkRef::Id("def".into()),
4113                LinkRef::Name("beta".into()),
4114            ]
4115        );
4116    }
4117
4118    #[test]
4119    fn list_orphans_counts_across_both_link_types() {
4120        let conn = setup();
4121        // `hub` named, referenced by `src-name`.
4122        insert_node(&conn, "hub", &named_doc("hub", "x")).unwrap();
4123        insert_node(&conn, "src-name", &referencing_doc(&["hub"])).unwrap();
4124        // `id-hub` referenced by an id-link from `src-id`.
4125        insert_node(&conn, "id-hub", &empty_doc()).unwrap();
4126        insert_node(&conn, "src-id", &link_doc(&["id-hub"])).unwrap();
4127        // `lonely` carries no incoming refs.
4128        insert_node(
4129            &conn,
4130            "lonely",
4131            &Document {
4132                blocks: vec![Block::Paragraph {
4133                    inlines: vec![Inline::Plain("no one links to me".into())],
4134                }],
4135            },
4136        )
4137        .unwrap();
4138        let orphans = list_orphans(&conn).unwrap();
4139        let ids: Vec<&str> = orphans.iter().map(|r| r.id.as_str()).collect();
4140        // Targets are NOT orphans regardless of link_type; everyone
4141        // else is.
4142        assert!(!ids.contains(&"hub"));
4143        assert!(!ids.contains(&"id-hub"));
4144        assert!(ids.contains(&"src-name"));
4145        assert!(ids.contains(&"src-id"));
4146        assert!(ids.contains(&"lonely"));
4147    }
4148
4149    #[test]
4150    fn list_hubs_ranks_by_total_in_degree_across_link_types() {
4151        let conn = setup();
4152        insert_node(&conn, "hot", &named_doc("hot", "x")).unwrap();
4153        insert_node(&conn, "warm", &empty_doc()).unwrap();
4154        // hot picks up two name-link incoming.
4155        insert_node(&conn, "a", &referencing_doc(&["hot"])).unwrap();
4156        insert_node(&conn, "b", &referencing_doc(&["hot"])).unwrap();
4157        // hot also picks up one id-link incoming -> total in-degree 3.
4158        insert_node(&conn, "c", &link_doc(&["hot"])).unwrap();
4159        // warm picks up one id-link incoming.
4160        insert_node(&conn, "d", &link_doc(&["warm"])).unwrap();
4161        let hubs = list_hubs(&conn, 10).unwrap();
4162        assert_eq!(hubs[0].id, "hot");
4163        assert_eq!(hubs[0].in_degree, 3);
4164        assert_eq!(hubs[1].id, "warm");
4165        assert_eq!(hubs[1].in_degree, 1);
4166    }
4167
4168    #[test]
4169    fn list_hubs_zero_limit_returns_empty() {
4170        let conn = setup();
4171        insert_node(&conn, "hot", &named_doc("hot", "x")).unwrap();
4172        insert_node(&conn, "a", &referencing_doc(&["hot"])).unwrap();
4173        assert!(list_hubs(&conn, 0).unwrap().is_empty());
4174    }
4175
4176    #[test]
4177    fn list_broken_links_returns_only_unresolved_name_rows() {
4178        let conn = setup();
4179        insert_node(&conn, "named", &named_doc("alpha", "x")).unwrap();
4180        insert_node(&conn, "src1", &referencing_doc(&["alpha", "missing"])).unwrap();
4181        insert_node(&conn, "src2", &referencing_doc(&["also-missing"])).unwrap();
4182        let broken = list_broken_links(&conn).unwrap();
4183        let slugs: Vec<&str> = broken
4184            .iter()
4185            .map(|r| r.target_slug.as_deref().unwrap_or(""))
4186            .collect();
4187        assert_eq!(slugs, vec!["also-missing", "missing"]);
4188        for row in &broken {
4189            assert_eq!(row.link_type, LinkType::Name);
4190            assert!(row.target_id.is_none());
4191        }
4192    }
4193
4194    #[test]
4195    fn get_links_returns_full_outgoing_including_broken() {
4196        let conn = setup();
4197        insert_node(&conn, "hub", &named_doc("hub", "x")).unwrap();
4198        insert_node(&conn, "src", &referencing_doc(&["hub", "ghost"])).unwrap();
4199        let nb = get_links(&conn, "src").unwrap();
4200        // Two outgoing rows; one resolved, one broken.
4201        assert_eq!(nb.outgoing.len(), 2);
4202        let resolved = nb
4203            .outgoing
4204            .iter()
4205            .find(|r| r.target_slug.as_deref() == Some("hub"))
4206            .unwrap();
4207        assert_eq!(resolved.target_id.as_deref(), Some("hub"));
4208        let broken = nb
4209            .outgoing
4210            .iter()
4211            .find(|r| r.target_slug.as_deref() == Some("ghost"))
4212            .unwrap();
4213        assert!(broken.target_id.is_none());
4214    }
4215
4216    #[test]
4217    fn relink_all_skips_corrupt_blob_in_unified_path() {
4218        let conn = setup();
4219        insert_node(&conn, "good", &named_doc("g", "x")).unwrap();
4220        conn.execute(
4221            "INSERT INTO nodes (id, title, ast_blob, created_at, updated_at) \
4222             VALUES ('bad', 'x', 'not-a-sexp', '0', '0')",
4223            [],
4224        )
4225        .unwrap();
4226        let (nodes, _) = relink_all(&conn).unwrap();
4227        assert_eq!(nodes, 2);
4228    }
4229}