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