Skip to main content

rto_graph/
store.rs

1//! SQLite-backed graph store.
2
3use std::path::Path;
4
5use rusqlite::{Connection, OptionalExtension, params};
6
7use crate::migrations;
8use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
9use crate::provenance::Provenance;
10
11/// Errors raised by the store.
12#[derive(Debug, thiserror::Error)]
13pub enum StoreError {
14    /// Underlying `SQLite` failure.
15    #[error("sqlite error: {0}")]
16    Sqlite(#[from] rusqlite::Error),
17    /// A node's `meta` could not be (de)serialized as JSON.
18    #[error("json error: {0}")]
19    Json(#[from] serde_json::Error),
20    /// An edge referenced a node key that does not exist in the store.
21    #[error("unknown node key: {0}")]
22    UnknownNode(String),
23    /// An edge violated the provenance/confidence invariant.
24    #[error("invalid edge: {0}")]
25    InvalidEdge(String),
26    /// A stored value could not be interpreted (database corruption).
27    #[error("corrupt store: {0}")]
28    Corrupt(String),
29}
30
31/// A summary of applying/re-applying import layers (see
32/// [`Store::apply_import_layer`] and [`Store::reapply_imports`]).
33#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
34pub struct ImportApplied {
35    /// Number of import layers processed.
36    pub layers: usize,
37    /// Import nodes upserted (across all layers).
38    pub nodes: usize,
39    /// Import edges applied — both endpoints resolved. A duplicate of an
40    /// already-present edge is a harmless no-op but still counted as applied.
41    pub edges_applied: usize,
42    /// Import edges **pruned**: an endpoint was absent (a cross-reference to code
43    /// that no longer exists), so the edge was dropped from the persisted layer
44    /// rather than kept as stale data.
45    pub edges_pruned: usize,
46}
47
48/// Qualified node columns for `SELECT`s that alias the `nodes` table as `n`.
49const NODE_COLS: &str = "n.key, n.kind, n.name, n.path, n.lang, n.blob_hash, n.span_start, n.span_end, n.provenance, n.meta";
50
51/// `SELECT` prefix that yields an [`Edge`] row (endpoints resolved back to keys).
52const EDGE_SELECT: &str = "SELECT ns.key AS src, nd.key AS dst, e.kind, e.provenance, \
53     e.confidence, e.src_ref \
54     FROM edges e JOIN nodes ns ON ns.id = e.src JOIN nodes nd ON nd.id = e.dst";
55
56/// A Roteiro graph store backed by a single `SQLite` database.
57pub struct Store {
58    conn: Connection,
59}
60
61impl Store {
62    /// Open (creating if absent) a store at `path` and apply pending migrations.
63    ///
64    /// # Errors
65    /// Returns [`StoreError::Sqlite`] if the database cannot be opened or a
66    /// migration fails.
67    pub fn open(path: &Path) -> Result<Self, StoreError> {
68        let conn = Connection::open(path)?;
69        Self::from_conn(conn)
70    }
71
72    /// Open an in-memory store (tests, previews).
73    ///
74    /// # Errors
75    /// Returns [`StoreError::Sqlite`] if a migration fails.
76    pub fn open_in_memory() -> Result<Self, StoreError> {
77        let conn = Connection::open_in_memory()?;
78        Self::from_conn(conn)
79    }
80
81    fn from_conn(mut conn: Connection) -> Result<Self, StoreError> {
82        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
83        // Wait briefly for a concurrent writer instead of failing a read with
84        // `database is locked`. Matters for workspace `serve` (ADR-0008), where a
85        // long-lived server reads a project's graph while that repo's own
86        // `roteiro sync` commits an update to the same file. Syncs are
87        // sub-second, so this only ever costs a short wait, never a lost query.
88        conn.busy_timeout(std::time::Duration::from_secs(5))?;
89        migrations::apply(&mut conn)?;
90        Ok(Self { conn })
91    }
92
93    /// The schema version this store has been migrated to.
94    ///
95    /// # Errors
96    /// Returns [`StoreError::Sqlite`] on query failure.
97    pub fn schema_version(&self) -> Result<u32, StoreError> {
98        let v: i64 = self.conn.query_row(
99            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
100            [],
101            |r| r.get(0),
102        )?;
103        Ok(u32::try_from(v).unwrap_or(0))
104    }
105
106    /// Number of nodes currently in the store.
107    ///
108    /// # Errors
109    /// Returns [`StoreError::Sqlite`] on query failure.
110    pub fn node_count(&self) -> Result<u64, StoreError> {
111        let n: i64 = self
112            .conn
113            .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
114        Ok(u64::try_from(n).unwrap_or(0))
115    }
116
117    /// Number of edges currently in the store.
118    ///
119    /// # Errors
120    /// Returns [`StoreError::Sqlite`] on query failure.
121    pub fn edge_count(&self) -> Result<u64, StoreError> {
122        let n: i64 = self
123            .conn
124            .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
125        Ok(u64::try_from(n).unwrap_or(0))
126    }
127
128    /// Insert or update a node, keyed by its natural [`Node::key`].
129    ///
130    /// # Errors
131    /// Returns [`StoreError::Json`] if `meta` cannot be serialized, or
132    /// [`StoreError::Sqlite`] on write failure.
133    pub fn upsert_node(&self, node: &Node) -> Result<(), StoreError> {
134        upsert_node(&self.conn, node)
135    }
136
137    /// Insert an edge. Both endpoints must already resolve to nodes.
138    ///
139    /// # Errors
140    /// Returns [`StoreError::InvalidEdge`] if the provenance/confidence
141    /// invariant is violated, [`StoreError::UnknownNode`] if an endpoint key is
142    /// absent, or [`StoreError::Sqlite`] on write failure.
143    pub fn insert_edge(&self, edge: &Edge) -> Result<(), StoreError> {
144        insert_edge(&self.conn, edge)
145    }
146
147    /// Apply a fact set atomically: all nodes are upserted, then all edges are
148    /// inserted, in a single transaction. On any error nothing is committed.
149    ///
150    /// # Errors
151    /// Returns the first error encountered (see [`Store::upsert_node`] and
152    /// [`Store::insert_edge`]); the transaction is rolled back.
153    pub fn apply_factset(&mut self, facts: &FactSet) -> Result<(), StoreError> {
154        let tx = self.conn.transaction()?;
155        for node in &facts.nodes {
156            upsert_node(&tx, node)?;
157        }
158        for edge in &facts.edges {
159            insert_edge(&tx, edge)?;
160        }
161        tx.commit()?;
162        Ok(())
163    }
164
165    /// The `HEAD` tree id recorded at the last successful [`Store::rebuild`], if
166    /// any. Used by the sync engine to detect an unchanged tree.
167    ///
168    /// # Errors
169    /// Returns [`StoreError::Sqlite`] on query failure.
170    pub fn sync_state(&self) -> Result<Option<String>, StoreError> {
171        Ok(self
172            .conn
173            .query_row("SELECT tree FROM sync_state WHERE id = 0", [], |r| r.get(0))
174            .optional()?)
175    }
176
177    /// The extractor environment recorded with the last committed [`sync`],
178    /// `None` if unset (a legacy row, or the last sync was a worktree/index
179    /// preview). The incremental committed `sync` compares this to the current
180    /// env and falls back to a full re-extraction when they differ.
181    ///
182    /// # Errors
183    /// Returns [`StoreError::Sqlite`] on query failure.
184    pub fn sync_env(&self) -> Result<Option<String>, StoreError> {
185        Ok(self
186            .conn
187            .query_row("SELECT env FROM sync_state WHERE id = 0", [], |r| r.get(0))
188            .optional()?
189            .flatten())
190    }
191
192    /// Record the extractor environment for the current synced tree. Called by a
193    /// committed `sync` right after it writes the tree, so a later sync can decide
194    /// whether the incremental fast path is sound. A no-op if no tree is recorded.
195    ///
196    /// # Errors
197    /// Returns [`StoreError::Sqlite`] on write failure.
198    pub fn set_sync_env(&self, env: &str) -> Result<(), StoreError> {
199        self.conn
200            .execute("UPDATE sync_state SET env = ?1 WHERE id = 0", [env])?;
201        Ok(())
202    }
203
204    /// Atomically replace the entire graph with `facts`, recording `tree` as the
205    /// synced state (or clearing it when `tree` is `None`). All existing nodes
206    /// and edges are deleted first, so the store reflects exactly the given fact
207    /// set.
208    ///
209    /// Passing `None` records *no* synced tree — distinct from an empty string —
210    /// so [`Store::sync_state`] returns `None` and a later `sync` will not
211    /// spuriously short-circuit.
212    ///
213    /// # Errors
214    /// Returns the first error encountered (see [`Store::apply_factset`]); on any
215    /// error nothing is committed.
216    pub fn rebuild(&mut self, facts: &FactSet, tree: Option<&str>) -> Result<(), StoreError> {
217        let tx = self.conn.transaction()?;
218        tx.execute("DELETE FROM edges", [])?;
219        tx.execute("DELETE FROM nodes", [])?;
220        for node in &facts.nodes {
221            upsert_node(&tx, node)?;
222        }
223        for edge in &facts.edges {
224            insert_edge(&tx, edge)?;
225        }
226        write_sync_state(&tx, tree)?;
227        tx.commit()?;
228        Ok(())
229    }
230
231    /// Bring the store to exactly `facts` (as [`Store::rebuild`] does) but writing
232    /// only what **differs** instead of wiping and reinserting the whole graph —
233    /// the git-style "write only the delta". Unchanged node rows (which carry the
234    /// heavy JSON `meta`) and unchanged edge rows are left untouched; only removed
235    /// rows are deleted and new/changed rows written. The final state — nodes,
236    /// edges, and `sync_state` — is identical to `rebuild(facts, tree)`.
237    ///
238    /// Leaving unchanged edges in place means their row ids do not match a cold
239    /// rebuild's — which is safe *because* every edge query is content-ordered
240    /// (`(src, dst, kind, provenance)`, see [`Store::all_edges`]), never by row
241    /// id. So an incrementally reconciled store and a fresh rebuild return every
242    /// query identically; the delta is invisible above the storage layer.
243    ///
244    /// # Errors
245    /// Returns [`StoreError`] on a query failure; the transaction is rolled back.
246    pub fn reconcile(&mut self, facts: &FactSet, tree: Option<&str>) -> Result<(), StoreError> {
247        let current_nodes = self.all_nodes()?;
248        let current_edges = self.all_edges()?;
249        let cur_by_key: std::collections::HashMap<&str, &Node> =
250            current_nodes.iter().map(|n| (n.key.as_str(), n)).collect();
251        let new_keys: std::collections::HashSet<&str> =
252            facts.nodes.iter().map(|n| n.key.as_str()).collect();
253
254        // Edge identity is the full tuple, so a changed `confidence`/`src_ref`
255        // counts as remove-old + add-new — keeping the result identical to a
256        // wholesale rebuild, not the store's insert-time `DO NOTHING` semantics.
257        let new_edge_ids: std::collections::HashSet<EdgeId> =
258            facts.edges.iter().map(edge_identity).collect();
259        let cur_edge_ids: std::collections::HashSet<EdgeId> =
260            current_edges.iter().map(edge_identity).collect();
261
262        let tx = self.conn.transaction()?;
263        // 1. Delete removed edges first, so any node they reference can then be
264        //    dropped (edges are FK-constrained on node ids, with no cascade). A
265        //    removed node's edges are all removals, so they are gone before step 2.
266        for edge in &current_edges {
267            if !new_edge_ids.contains(&edge_identity(edge)) {
268                delete_edge(&tx, edge)?;
269            }
270        }
271        // 2. Drop nodes that no longer exist.
272        for old in &current_nodes {
273            if !new_keys.contains(old.key.as_str()) {
274                tx.execute("DELETE FROM nodes WHERE key = ?1", [&old.key])?;
275            }
276        }
277        // 3. Upsert only the nodes that are new or whose content changed (an upsert
278        //    keeps the row id, so unchanged edges stay valid).
279        for node in &facts.nodes {
280            if cur_by_key
281                .get(node.key.as_str())
282                .is_none_or(|cur| *cur != node)
283            {
284                upsert_node(&tx, node)?;
285            }
286        }
287        // 4. Insert only the added edges (their endpoints now all exist).
288        for edge in &facts.edges {
289            if !cur_edge_ids.contains(&edge_identity(edge)) {
290                insert_edge(&tx, edge)?;
291            }
292        }
293        write_sync_state(&tx, tree)?;
294        tx.commit()?;
295        Ok(())
296    }
297
298    /// Fetch a node by its natural key.
299    ///
300    /// # Errors
301    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
302    /// [`StoreError::Corrupt`] if a stored value cannot be decoded.
303    pub fn get_node(&self, key: &str) -> Result<Option<Node>, StoreError> {
304        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.key = ?1");
305        let mut stmt = self.conn.prepare(&sql)?;
306        let mut rows = stmt.query([key])?;
307        match rows.next()? {
308            Some(row) => Ok(Some(row_to_node(row)?)),
309            None => Ok(None),
310        }
311    }
312
313    /// Every node key in the store, ordered. Useful for whole-graph exports.
314    ///
315    /// # Errors
316    /// Returns [`StoreError::Sqlite`] on query failure.
317    pub fn all_keys(&self) -> Result<Vec<String>, StoreError> {
318        let mut stmt = self.conn.prepare("SELECT key FROM nodes ORDER BY key")?;
319        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
320        let mut out = Vec::new();
321        for row in rows {
322            out.push(row?);
323        }
324        Ok(out)
325    }
326
327    /// Dump the entire graph as a single [`FactSet`], with nodes and edges in a
328    /// deterministic order — suitable for a portable, content-stable artifact.
329    ///
330    /// # Errors
331    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
332    /// [`StoreError::Corrupt`] on decode failure.
333    pub fn export_factset(&self) -> Result<FactSet, StoreError> {
334        let node_sql = format!("SELECT {NODE_COLS} FROM nodes n ORDER BY n.key");
335        let mut node_stmt = self.conn.prepare(&node_sql)?;
336        let mut node_rows = node_stmt.query([])?;
337        let nodes = collect_nodes(&mut node_rows)?;
338
339        // Order edges by their resolved endpoint keys (not row id) so the dump is
340        // stable regardless of insertion order.
341        let edge_sql = format!("{EDGE_SELECT} ORDER BY ns.key, nd.key, e.kind, e.provenance");
342        let mut edge_stmt = self.conn.prepare(&edge_sql)?;
343        let mut edge_rows = edge_stmt.query([])?;
344        let edges = collect_edges(&mut edge_rows)?;
345
346        Ok(FactSet { nodes, edges })
347    }
348
349    /// All nodes of a given kind.
350    ///
351    /// # Errors
352    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
353    /// [`StoreError::Corrupt`] on decode failure.
354    pub fn nodes_by_kind(&self, kind: &NodeKind) -> Result<Vec<Node>, StoreError> {
355        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.kind = ?1 ORDER BY n.key");
356        let mut stmt = self.conn.prepare(&sql)?;
357        let mut rows = stmt.query([kind.as_str()])?;
358        collect_nodes(&mut rows)
359    }
360
361    /// Nodes of a given kind whose `name` equals `name_lower` **case-insensitively**,
362    /// ordered by key. Narrows a lookup at the SQL layer — using the `kind` index and
363    /// filtering `name` in-query — so only matching rows are decoded, never every
364    /// node of that kind. Used by the cross-repo follow bridge to fetch just the
365    /// candidate struct(s) for a config section rather than scanning all structs.
366    ///
367    /// # Errors
368    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
369    /// [`StoreError::Corrupt`] on decode failure.
370    pub fn nodes_by_kind_named(
371        &self,
372        kind: &NodeKind,
373        name_lower: &str,
374    ) -> Result<Vec<Node>, StoreError> {
375        let sql = format!(
376            "SELECT {NODE_COLS} FROM nodes n \
377             WHERE n.kind = ?1 AND lower(n.name) = ?2 ORDER BY n.key"
378        );
379        let mut stmt = self.conn.prepare(&sql)?;
380        let mut rows = stmt.query([kind.as_str(), name_lower])?;
381        collect_nodes(&mut rows)
382    }
383
384    /// Every `config_key` node's flattened setting (ADR-0009), read back out of
385    /// the graph as [`crate::ConfigKey`]s — the graph-native source the cross-repo
386    /// link matcher (`roteiro links --infer`) consumes, so it never re-parses
387    /// config files. Ordered by node key (deterministic). A node missing the
388    /// `key`/`path` a well-formed `config_key` carries is skipped defensively.
389    ///
390    /// # Errors
391    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
392    /// [`StoreError::Corrupt`] on decode failure.
393    pub fn config_keys(&self) -> Result<Vec<crate::ConfigKey>, StoreError> {
394        let nodes = self.nodes_by_kind(&NodeKind::Other(crate::config_keys::KIND.to_owned()))?;
395        let mut out = Vec::with_capacity(nodes.len());
396        for n in &nodes {
397            let key = n.meta.get("key").and_then(serde_json::Value::as_str);
398            // A `config_key` node carries a `value` in `meta` when it has a real
399            // setting (file-derived keys always do, even an empty string). A
400            // struct-derived key (`meta.source = "struct"`) omits it — its value is
401            // *unknown*, not empty — so record that absence explicitly rather than
402            // defaulting it to `""`, which would false-match in value agreement.
403            let value = n.meta.get("value").and_then(serde_json::Value::as_str);
404            if let (Some(key), Some(file)) = (key, n.path.as_deref()) {
405                out.push(crate::ConfigKey {
406                    file: file.to_owned(),
407                    key: key.to_owned(),
408                    value: value.unwrap_or_default().to_owned(),
409                    value_known: value.is_some(),
410                });
411            }
412        }
413        Ok(out)
414    }
415
416    /// Every node whose source `path` is `path`, ordered by key — the file node
417    /// plus the symbols and markers defined in it. Used to scope a change to the
418    /// graph (e.g. `roteiro review`).
419    ///
420    /// # Errors
421    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
422    /// [`StoreError::Corrupt`] on decode failure.
423    pub fn nodes_by_path(&self, path: &str) -> Result<Vec<Node>, StoreError> {
424        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.path = ?1 ORDER BY n.key");
425        let mut stmt = self.conn.prepare(&sql)?;
426        let mut rows = stmt.query([path])?;
427        collect_nodes(&mut rows)
428    }
429
430    /// Every node produced by a given layer, ordered by key. The incremental
431    /// `sync` loads the `Derived` layer to reconstruct the extraction graph
432    /// without re-reading every blob.
433    ///
434    /// # Errors
435    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
436    /// [`StoreError::Corrupt`] on decode failure.
437    pub fn nodes_by_provenance(&self, provenance: Provenance) -> Result<Vec<Node>, StoreError> {
438        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.provenance = ?1 ORDER BY n.key");
439        let mut stmt = self.conn.prepare(&sql)?;
440        let mut rows = stmt.query([provenance.as_str()])?;
441        collect_nodes(&mut rows)
442    }
443
444    /// Every node in the store, ordered by key. Unlike [`Store::export_factset`]
445    /// this decodes no edges, so it is cheap for node-only scans (e.g. search).
446    ///
447    /// # Errors
448    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
449    /// [`StoreError::Corrupt`] on decode failure.
450    pub fn all_nodes(&self) -> Result<Vec<Node>, StoreError> {
451        let sql = format!("SELECT {NODE_COLS} FROM nodes n ORDER BY n.key");
452        let mut stmt = self.conn.prepare(&sql)?;
453        let mut rows = stmt.query([])?;
454        collect_nodes(&mut rows)
455    }
456
457    /// Every edge in the store, with endpoints resolved to their node keys. Used
458    /// by [`Store::reconcile`] to diff the edge set.
459    ///
460    /// Ordered by the edge's **content** — `(src key, dst key, kind, provenance)`,
461    /// the table's unique tuple — not by row id. This makes the order a function
462    /// of the *graph*, not of insertion history, so an incrementally
463    /// [`reconcile`](Store::reconcile)d store and a cold [`rebuild`](Store::rebuild)
464    /// return edges identically. (The same reason node scans order by `key`.)
465    ///
466    /// # Errors
467    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
468    pub fn all_edges(&self) -> Result<Vec<Edge>, StoreError> {
469        let sql = format!("{EDGE_SELECT} ORDER BY ns.key, nd.key, e.kind, e.provenance");
470        let mut stmt = self.conn.prepare(&sql)?;
471        let mut rows = stmt.query([])?;
472        collect_edges(&mut rows)
473    }
474
475    /// Edges whose source is the node with the given key, in content order
476    /// (`(dst key, kind, provenance)` — `src` is fixed). Content-ordered rather
477    /// than by row id so the result is history-independent; see [`Store::all_edges`].
478    ///
479    /// # Errors
480    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
481    pub fn edges_from(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
482        let sql = format!("{EDGE_SELECT} WHERE ns.key = ?1 ORDER BY nd.key, e.kind, e.provenance");
483        let mut stmt = self.conn.prepare(&sql)?;
484        let mut rows = stmt.query([key])?;
485        collect_edges(&mut rows)
486    }
487
488    /// Edges whose destination is the node with the given key, in content order
489    /// (`(src key, kind, provenance)` — `dst` is fixed). See [`Store::all_edges`].
490    ///
491    /// # Errors
492    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
493    pub fn edges_to(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
494        let sql = format!("{EDGE_SELECT} WHERE nd.key = ?1 ORDER BY ns.key, e.kind, e.provenance");
495        let mut stmt = self.conn.prepare(&sql)?;
496        let mut rows = stmt.query([key])?;
497        collect_edges(&mut rows)
498    }
499
500    /// All edges with the given provenance, in content order
501    /// (`(src key, dst key, kind)` — `provenance` is fixed). See [`Store::all_edges`].
502    ///
503    /// # Errors
504    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
505    pub fn edges_by_provenance(&self, provenance: Provenance) -> Result<Vec<Edge>, StoreError> {
506        let sql = format!("{EDGE_SELECT} WHERE e.provenance = ?1 ORDER BY ns.key, nd.key, e.kind");
507        let mut stmt = self.conn.prepare(&sql)?;
508        let mut rows = stmt.query([provenance.as_str()])?;
509        collect_edges(&mut rows)
510    }
511
512    /// Delete all edges with the given provenance, returning how many were
513    /// removed. Used to re-derive a whole provenance class authoritatively (e.g.
514    /// `inferred` edges when re-running inference with different parameters).
515    ///
516    /// # Errors
517    /// Returns [`StoreError::Sqlite`] on write failure.
518    pub fn delete_edges_by_provenance(&self, provenance: Provenance) -> Result<u64, StoreError> {
519        let n = self.conn.execute(
520            "DELETE FROM edges WHERE provenance = ?1",
521            [provenance.as_str()],
522        )?;
523        Ok(u64::try_from(n).unwrap_or(0))
524    }
525
526    /// Delete all edges carrying the given `src_ref`, returning how many were
527    /// removed. Lets one producer of `inferred` edges (e.g. the embedding layer,
528    /// or a Graphify import) re-derive its own edges authoritatively without
529    /// touching edges another producer contributed.
530    ///
531    /// # Errors
532    /// Returns [`StoreError::Sqlite`] on write failure.
533    pub fn delete_edges_by_src_ref(&self, src_ref: &str) -> Result<u64, StoreError> {
534        let n = self
535            .conn
536            .execute("DELETE FROM edges WHERE src_ref = ?1", [src_ref])?;
537        Ok(u64::try_from(n).unwrap_or(0))
538    }
539
540    /// Apply an import layer to the live graph **and** persist it durably under
541    /// `src_ref`, validating as it goes: this ref's prior edges are cleared
542    /// (an authoritative re-import), the layer's nodes are upserted, and each
543    /// edge is applied only if both endpoints resolve. Dangling edges — cross-
544    /// references to code that is not present — are dropped, and only the
545    /// validated (trimmed) layer is persisted, so stale data is never stored.
546    ///
547    /// This is the "validate on import" half; [`Store::reapply_imports`] is the
548    /// "validate on sync" half, re-checking layers against the rebuilt graph.
549    ///
550    /// # Errors
551    /// Returns [`StoreError::Json`] if `facts` cannot be (de)serialized,
552    /// [`StoreError::InvalidEdge`] on a malformed edge, or [`StoreError::Sqlite`]
553    /// on write failure.
554    pub fn apply_import_layer(
555        &mut self,
556        src_ref: &str,
557        facts: &FactSet,
558    ) -> Result<ImportApplied, StoreError> {
559        let tx = self.conn.transaction()?;
560        // Authoritative re-import: drop this ref's prior edges from the live graph.
561        tx.execute("DELETE FROM edges WHERE src_ref = ?1", [src_ref])?;
562        for node in &facts.nodes {
563            upsert_node(&tx, node)?;
564        }
565        let (kept, applied) = apply_edges_pruning(&tx, &facts.edges)?;
566        let trimmed = FactSet {
567            nodes: facts.nodes.clone(),
568            edges: kept,
569        };
570        put_import_row(&tx, src_ref, &trimmed)?;
571        tx.commit()?;
572        Ok(ImportApplied {
573            layers: 1,
574            nodes: facts.nodes.len(),
575            ..applied
576        })
577    }
578
579    /// Remove the persisted import layer for `src_ref`, returning whether one
580    /// existed. Does not remove edges already in the live graph (use
581    /// [`Store::delete_edges_by_src_ref`] for that).
582    ///
583    /// # Errors
584    /// Returns [`StoreError::Sqlite`] on write failure.
585    pub fn delete_import(&self, src_ref: &str) -> Result<bool, StoreError> {
586        let n = self
587            .conn
588            .execute("DELETE FROM imports WHERE src_ref = ?1", [src_ref])?;
589        Ok(n > 0)
590    }
591
592    /// The `src_ref`s of all persisted import layers, ordered.
593    ///
594    /// # Errors
595    /// Returns [`StoreError::Sqlite`] on query failure.
596    pub fn import_refs(&self) -> Result<Vec<String>, StoreError> {
597        let mut stmt = self
598            .conn
599            .prepare("SELECT src_ref FROM imports ORDER BY src_ref")?;
600        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
601        let mut out = Vec::new();
602        for row in rows {
603            out.push(row?);
604        }
605        Ok(out)
606    }
607
608    /// Re-apply every persisted import layer on top of the current graph and
609    /// **re-validate** it: all import nodes are upserted first (so cross-layer
610    /// and self references resolve), then each edge is applied; an edge whose
611    /// endpoint is now absent — a cross-reference to code a sync removed — is
612    /// pruned from the persisted layer, not merely skipped. So the durable store
613    /// keeps only still-correct data. Idempotent; safe to run after each rebuild.
614    ///
615    /// # Errors
616    /// Returns [`StoreError::Json`] if a stored layer cannot be (de)serialized,
617    /// or [`StoreError::Sqlite`] on write failure.
618    pub fn reapply_imports(&mut self) -> Result<ImportApplied, StoreError> {
619        let layers = self.load_import_layers()?;
620        let tx = self.conn.transaction()?;
621        // Pass 1: upsert every layer's nodes so intra-import edges resolve
622        // regardless of which layer defines the endpoint.
623        for (_, facts) in &layers {
624            for node in &facts.nodes {
625                upsert_node(&tx, node)?;
626            }
627        }
628        // Pass 2: apply edges, pruning (and rewriting) any that dangle.
629        let mut applied = ImportApplied {
630            layers: layers.len(),
631            ..ImportApplied::default()
632        };
633        for (src_ref, facts) in &layers {
634            applied.nodes += facts.nodes.len();
635            let (kept, counts) = apply_edges_pruning(&tx, &facts.edges)?;
636            applied.edges_applied += counts.edges_applied;
637            applied.edges_pruned += counts.edges_pruned;
638            if kept.len() != facts.edges.len() {
639                let trimmed = FactSet {
640                    nodes: facts.nodes.clone(),
641                    edges: kept,
642                };
643                put_import_row(&tx, src_ref, &trimmed)?;
644            }
645        }
646        tx.commit()?;
647        Ok(applied)
648    }
649
650    /// Load and decode every persisted import layer as `(src_ref, FactSet)`, in
651    /// `src_ref` order.
652    fn load_import_layers(&self) -> Result<Vec<(String, FactSet)>, StoreError> {
653        let mut stmt = self
654            .conn
655            .prepare("SELECT src_ref, facts FROM imports ORDER BY src_ref")?;
656        let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
657        let mut out = Vec::new();
658        for row in rows {
659            let (src_ref, json) = row?;
660            let mut facts: FactSet = serde_json::from_str(&json)?;
661            // An import-layer node is *never* derived (derivation is `sync`'s job),
662            // so a `Derived` tag here is always wrong. It arises two ways, both
663            // repaired the same: a legacy layer persisted before nodes carried
664            // provenance (the field is absent → serde defaults `Derived`), or —
665            // anomalously — a layer that stored an explicit `"provenance":"derived"`
666            // (a producer/data bug). We deliberately repair *both* rather than only
667            // the absent case: leaving an explicit-`derived` import node in place
668            // would let a layer-scoped `sync` treat it as derived and delete it —
669            // the exact corruption this guards against — so repair is the safe
670            // recovery, not silent masking. Idempotent, runs on every reapply
671            // (old stores self-heal), and a no-op for correctly-tagged fresh imports.
672            for node in &mut facts.nodes {
673                if node.provenance == Provenance::Derived {
674                    node.provenance = import_node_provenance(&node.key);
675                }
676            }
677            out.push((src_ref, facts));
678        }
679        Ok(out)
680    }
681
682    /// Neighbouring nodes reachable from `key` in the given direction. Returns
683    /// an empty vector if the node does not exist.
684    ///
685    /// # Errors
686    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
687    /// [`StoreError::Corrupt`] on failure.
688    pub fn neighbors(&self, key: &str, dir: Direction) -> Result<Vec<Node>, StoreError> {
689        let out = format!(
690            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.dst \
691             JOIN nodes s ON s.id = e.src WHERE s.key = ?1"
692        );
693        let inc = format!(
694            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.src \
695             JOIN nodes d ON d.id = e.dst WHERE d.key = ?1"
696        );
697        // Order by output column 1 (the node key) so results are deterministic
698        // across SQLite versions/plans. Positional ordering avoids both the
699        // ambiguity of a bare `key` (present in every joined table) and the fact
700        // that a table-qualified name cannot be used after the `Both` UNION.
701        let sql = match dir {
702            Direction::Outgoing => format!("{out} ORDER BY 1"),
703            Direction::Incoming => format!("{inc} ORDER BY 1"),
704            Direction::Both => format!("{out} UNION {inc} ORDER BY 1"),
705        };
706        let mut stmt = self.conn.prepare(&sql)?;
707        let mut rows = stmt.query([key])?;
708        collect_nodes(&mut rows)
709    }
710
711    /// Fetch the cached context bundle for `key` as `(fingerprint, json)`, if
712    /// present. The caller compares the fingerprint to the node's current one to
713    /// decide whether the entry is fresh (see [`crate::context`]).
714    ///
715    /// # Errors
716    /// Returns [`StoreError::Sqlite`] on query failure.
717    pub fn context_cache_get(&self, key: &str) -> Result<Option<(String, String)>, StoreError> {
718        let row = self
719            .conn
720            .query_row(
721                "SELECT fingerprint, json FROM node_context WHERE key = ?1",
722                [key],
723                |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
724            )
725            .optional()?;
726        Ok(row)
727    }
728
729    /// Fetch just the cached fingerprint for `key`, without reading the (larger)
730    /// JSON payload — for a cheap freshness check.
731    ///
732    /// # Errors
733    /// Returns [`StoreError::Sqlite`] on query failure.
734    pub fn context_cache_fingerprint(&self, key: &str) -> Result<Option<String>, StoreError> {
735        let fp = self
736            .conn
737            .query_row(
738                "SELECT fingerprint FROM node_context WHERE key = ?1",
739                [key],
740                |r| r.get::<_, String>(0),
741            )
742            .optional()?;
743        Ok(fp)
744    }
745
746    /// Store (or replace) the cached context bundle for `key`.
747    ///
748    /// # Errors
749    /// Returns [`StoreError::Sqlite`] on write failure.
750    pub fn context_cache_put(
751        &self,
752        key: &str,
753        fingerprint: &str,
754        json: &str,
755    ) -> Result<(), StoreError> {
756        self.conn.execute(
757            "INSERT INTO node_context (key, fingerprint, json) VALUES (?1, ?2, ?3)
758             ON CONFLICT(key) DO UPDATE SET
759                 fingerprint = excluded.fingerprint, json = excluded.json",
760            [key, fingerprint, json],
761        )?;
762        Ok(())
763    }
764
765    /// Delete the cached context entry for `key`, returning whether one existed.
766    ///
767    /// # Errors
768    /// Returns [`StoreError::Sqlite`] on write failure.
769    pub fn context_cache_delete(&self, key: &str) -> Result<bool, StoreError> {
770        let n = self
771            .conn
772            .execute("DELETE FROM node_context WHERE key = ?1", [key])?;
773        Ok(n > 0)
774    }
775
776    /// Every key with a cached context entry, ordered. Used to prune entries for
777    /// nodes that no longer exist.
778    ///
779    /// # Errors
780    /// Returns [`StoreError::Sqlite`] on query failure.
781    pub fn context_cache_keys(&self) -> Result<Vec<String>, StoreError> {
782        let mut stmt = self
783            .conn
784            .prepare("SELECT key FROM node_context ORDER BY key")?;
785        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
786        let mut out = Vec::new();
787        for row in rows {
788            out.push(row?);
789        }
790        Ok(out)
791    }
792}
793
794// --- Free helpers operating on a `Connection` (a `Transaction` derefs to one) ---
795
796fn node_row_id(conn: &Connection, key: &str) -> rusqlite::Result<Option<i64>> {
797    conn.query_row("SELECT id FROM nodes WHERE key = ?1", [key], |r| r.get(0))
798        .optional()
799}
800
801/// Record (or clear) the last-synced `HEAD` tree id. Shared by `rebuild` and
802/// `reconcile` so both leave identical `sync_state`.
803fn write_sync_state(conn: &Connection, tree: Option<&str>) -> Result<(), StoreError> {
804    match tree {
805        // Clear `env` on every tree write: it is only valid for the tree a
806        // committed `sync` set it against, and that sync re-records it (via
807        // `set_sync_env`) immediately after. So a worktree/index sync, or any
808        // path that does not re-set it, leaves `env` NULL — reading as "unknown"
809        // and forcing the safe full re-extraction next time.
810        Some(tree) => conn.execute(
811            "INSERT INTO sync_state (id, tree) VALUES (0, ?1)
812             ON CONFLICT(id) DO UPDATE SET tree = excluded.tree, env = NULL",
813            [tree],
814        )?,
815        None => conn.execute("DELETE FROM sync_state WHERE id = 0", [])?,
816    };
817    Ok(())
818}
819
820fn upsert_node(conn: &Connection, node: &Node) -> Result<(), StoreError> {
821    let meta = serde_json::to_string(&node.meta)?;
822    let (span_start, span_end) = match node.span {
823        Some(s) => (Some(i64::from(s.start)), Some(i64::from(s.end))),
824        None => (None, None),
825    };
826    conn.execute(
827        "INSERT INTO nodes (key, kind, name, path, lang, blob_hash, span_start, span_end, provenance, meta)
828         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
829         ON CONFLICT(key) DO UPDATE SET
830             kind = excluded.kind, name = excluded.name, path = excluded.path,
831             lang = excluded.lang, blob_hash = excluded.blob_hash,
832             span_start = excluded.span_start, span_end = excluded.span_end,
833             provenance = excluded.provenance, meta = excluded.meta",
834        params![
835            node.key,
836            node.kind.as_str(),
837            node.name,
838            node.path,
839            node.lang,
840            node.blob_hash,
841            span_start,
842            span_end,
843            node.provenance.as_str(),
844            meta,
845        ],
846    )?;
847    Ok(())
848}
849
850fn insert_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
851    validate_edge(edge)?;
852    let src_id =
853        node_row_id(conn, &edge.src)?.ok_or_else(|| StoreError::UnknownNode(edge.src.clone()))?;
854    let dst_id =
855        node_row_id(conn, &edge.dst)?.ok_or_else(|| StoreError::UnknownNode(edge.dst.clone()))?;
856    insert_edge_row(conn, edge, src_id, dst_id)
857}
858
859/// Apply `edge` only if both endpoints already resolve to nodes, returning
860/// whether it was **applied** (both endpoints resolved; a duplicate of an
861/// existing edge is a harmless no-op via `ON CONFLICT DO NOTHING` but still
862/// reports `true`). A missing endpoint returns `false` rather than erroring —
863/// the caller prunes such dangling cross-references from the import layer.
864fn insert_edge_if_present(conn: &Connection, edge: &Edge) -> Result<bool, StoreError> {
865    validate_edge(edge)?;
866    let (Some(src_id), Some(dst_id)) =
867        (node_row_id(conn, &edge.src)?, node_row_id(conn, &edge.dst)?)
868    else {
869        return Ok(false);
870    };
871    insert_edge_row(conn, edge, src_id, dst_id)?;
872    Ok(true)
873}
874
875/// Apply `edges`, keeping those whose endpoints resolve and pruning the rest.
876/// Returns the kept edges plus the applied/pruned counts (in an [`ImportApplied`]
877/// whose `layers`/`nodes` are left zero for the caller to fill).
878fn apply_edges_pruning(
879    conn: &Connection,
880    edges: &[Edge],
881) -> Result<(Vec<Edge>, ImportApplied), StoreError> {
882    let mut kept = Vec::with_capacity(edges.len());
883    let mut counts = ImportApplied::default();
884    for edge in edges {
885        if insert_edge_if_present(conn, edge)? {
886            kept.push(edge.clone());
887            counts.edges_applied += 1;
888        } else {
889            counts.edges_pruned += 1;
890        }
891    }
892    Ok((kept, counts))
893}
894
895/// Upsert a persisted import layer row. Free helper so it can run inside the same
896/// transaction as an apply/prune pass.
897fn put_import_row(conn: &Connection, src_ref: &str, facts: &FactSet) -> Result<(), StoreError> {
898    let json = serde_json::to_string(facts)?;
899    conn.execute(
900        "INSERT INTO imports (src_ref, facts) VALUES (?1, ?2)
901         ON CONFLICT(src_ref) DO UPDATE SET facts = excluded.facts, imported_at = datetime('now')",
902        params![src_ref, json],
903    )?;
904    Ok(())
905}
906
907/// The provenance/confidence invariant guard shared by the strict and tolerant
908/// edge inserts.
909fn validate_edge(edge: &Edge) -> Result<(), StoreError> {
910    if edge.is_valid() {
911        Ok(())
912    } else {
913        Err(StoreError::InvalidEdge(format!(
914            "confidence must be present iff provenance is inferred (src={}, dst={})",
915            edge.src, edge.dst
916        )))
917    }
918}
919
920/// Insert an edge row given already-resolved endpoint ids. Edges are a set: a
921/// duplicate `(src, dst, kind, provenance)` is a no-op via `ON CONFLICT … DO
922/// NOTHING`, so re-applying a fact set never accumulates duplicates. Other
923/// constraint violations (guarded in Rust above) still surface.
924fn insert_edge_row(
925    conn: &Connection,
926    edge: &Edge,
927    src_id: i64,
928    dst_id: i64,
929) -> Result<(), StoreError> {
930    conn.execute(
931        "INSERT INTO edges (src, dst, kind, provenance, confidence, src_ref)
932         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
933         ON CONFLICT(src, dst, kind, provenance) DO NOTHING",
934        params![
935            src_id,
936            dst_id,
937            edge.kind.as_str(),
938            edge.provenance.as_str(),
939            edge.confidence,
940            edge.src_ref,
941        ],
942    )?;
943    Ok(())
944}
945
946/// A hashable identity for an edge over **all** its fields — used by
947/// [`Store::reconcile`] to diff the edge set. A tuple (not a delimiter-joined
948/// string) so no field value can be confused with a separator: node keys embed
949/// git paths, which may legally contain any byte (including control characters),
950/// so a joined string could collapse distinct edges to one identity and drop an
951/// edge. Confidence is compared by its exact bit pattern (`f64::to_bits`, wrapped
952/// in `Option` so `None` and `Some(_)` stay distinct), the only non-`Eq` field.
953fn edge_identity(edge: &Edge) -> EdgeId {
954    (
955        edge.src.clone(),
956        edge.dst.clone(),
957        edge.kind.as_str().to_owned(),
958        edge.provenance.as_str().to_owned(),
959        edge.confidence.map(f64::to_bits),
960        edge.src_ref.clone(),
961    )
962}
963
964/// The tuple form of an edge's full-field identity (see [`edge_identity`]):
965/// `(src, dst, kind, provenance, confidence-bits, src_ref)`.
966type EdgeId = (String, String, String, String, Option<u64>, Option<String>);
967
968/// Delete the edge row identified by `(src, dst, kind, provenance)` — the table's
969/// unique key — resolving the endpoint node keys to ids. A no-op if absent.
970fn delete_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
971    conn.execute(
972        "DELETE FROM edges
973         WHERE src = (SELECT id FROM nodes WHERE key = ?1)
974           AND dst = (SELECT id FROM nodes WHERE key = ?2)
975           AND kind = ?3 AND provenance = ?4",
976        params![
977            edge.src,
978            edge.dst,
979            edge.kind.as_str(),
980            edge.provenance.as_str()
981        ],
982    )?;
983    Ok(())
984}
985
986fn collect_nodes(rows: &mut rusqlite::Rows) -> Result<Vec<Node>, StoreError> {
987    let mut out = Vec::new();
988    while let Some(row) = rows.next()? {
989        out.push(row_to_node(row)?);
990    }
991    Ok(out)
992}
993
994fn collect_edges(rows: &mut rusqlite::Rows) -> Result<Vec<Edge>, StoreError> {
995    let mut out = Vec::new();
996    while let Some(row) = rows.next()? {
997        out.push(row_to_edge(row)?);
998    }
999    Ok(out)
1000}
1001
1002fn row_to_node(row: &rusqlite::Row) -> Result<Node, StoreError> {
1003    let kind: String = row.get("kind")?;
1004    let span_start: Option<i64> = row.get("span_start")?;
1005    let span_end: Option<i64> = row.get("span_end")?;
1006    let span = match (span_start, span_end) {
1007        (Some(s), Some(e)) => Some(Span::new(to_u32(s)?, to_u32(e)?)),
1008        _ => None,
1009    };
1010    let meta: String = row.get("meta")?;
1011    let provenance: String = row.get("provenance")?;
1012    let provenance = Provenance::from_token(&provenance)
1013        .ok_or_else(|| StoreError::Corrupt(format!("unknown node provenance: {provenance}")))?;
1014    Ok(Node {
1015        key: row.get("key")?,
1016        kind: NodeKind::from_token(&kind),
1017        name: row.get("name")?,
1018        path: row.get("path")?,
1019        lang: row.get("lang")?,
1020        blob_hash: row.get("blob_hash")?,
1021        span,
1022        provenance,
1023        meta: serde_json::from_str(&meta)?,
1024    })
1025}
1026
1027fn row_to_edge(row: &rusqlite::Row) -> Result<Edge, StoreError> {
1028    let kind: String = row.get("kind")?;
1029    let provenance: String = row.get("provenance")?;
1030    let provenance = Provenance::from_token(&provenance)
1031        .ok_or_else(|| StoreError::Corrupt(format!("unknown provenance: {provenance}")))?;
1032    Ok(Edge {
1033        src: row.get("src")?,
1034        dst: row.get("dst")?,
1035        kind: EdgeKind::from_token(&kind),
1036        provenance,
1037        confidence: row.get("confidence")?,
1038        src_ref: row.get("src_ref")?,
1039    })
1040}
1041
1042fn to_u32(v: i64) -> Result<u32, StoreError> {
1043    u32::try_from(v).map_err(|_| StoreError::Corrupt(format!("span offset out of range: {v}")))
1044}
1045
1046/// The true provenance of an import-layer node, from its key namespace: Graphify
1047/// nodes (`graphify:`) are [`Provenance::Inferred`]; every other import node (lat,
1048/// …) is [`Provenance::Authored`]. Import-layer nodes are never derived, so this
1049/// is used to repair a legacy `Derived` tag on load (see `load_import_layers`).
1050fn import_node_provenance(key: &str) -> Provenance {
1051    if key.starts_with("graphify:") {
1052        Provenance::Inferred
1053    } else {
1054        Provenance::Authored
1055    }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::Store;
1061    use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
1062    use crate::provenance::Provenance;
1063
1064    fn sample_node(key: &str) -> Node {
1065        Node {
1066            key: key.to_owned(),
1067            kind: NodeKind::Fn,
1068            name: "sample".to_owned(),
1069            path: Some("src/lib.rs".to_owned()),
1070            lang: Some("rust".to_owned()),
1071            blob_hash: Some("deadbeef".to_owned()),
1072            span: Some(Span::new(10, 42)),
1073            provenance: Provenance::Derived,
1074            meta: serde_json::json!({"vis": "pub"}),
1075        }
1076    }
1077
1078    #[test]
1079    fn reconcile_matches_a_full_rebuild() {
1080        // reconcile must leave the store identical to a fresh rebuild, across an
1081        // add, a remove, a content change, and edge churn.
1082        let node = |k: &str, name: &str| {
1083            let mut n = sample_node(k);
1084            n.name = name.to_owned();
1085            n
1086        };
1087        let edge =
1088            |src: &str, dst: &str| Edge::derived(src.to_owned(), dst.to_owned(), EdgeKind::Calls);
1089        // An inferred edge carries confidence and a src_ref — exercise both so the
1090        // equivalence claim covers every edge field, not just derived calls.
1091        let inferred = |src: &str, dst: &str, conf: f64| {
1092            let mut e = Edge::inferred(src.to_owned(), dst.to_owned(), EdgeKind::Related, conf);
1093            e.src_ref = Some("import:demo".to_owned());
1094            e
1095        };
1096
1097        let facts1 = FactSet {
1098            nodes: vec![node("a", "A"), node("b", "B"), node("c", "C")],
1099            edges: vec![edge("a", "b"), edge("b", "c"), inferred("a", "c", 0.7)],
1100        };
1101        // b changes (name), c is removed, d is added; edge b->c drops, a->d added,
1102        // and the inferred edge's confidence changes.
1103        let facts2 = FactSet {
1104            nodes: vec![node("a", "A"), node("b", "B2"), node("d", "D")],
1105            edges: vec![edge("a", "b"), edge("a", "d"), inferred("a", "d", 0.9)],
1106        };
1107
1108        // Path 1: rebuild facts1, then reconcile to facts2.
1109        let mut reconciled = Store::open_in_memory().expect("open");
1110        reconciled.rebuild(&facts1, Some("t1")).expect("rebuild");
1111        reconciled
1112            .reconcile(&facts2, Some("t2"))
1113            .expect("reconcile");
1114
1115        // Path 2: a fresh full rebuild of facts2.
1116        let mut rebuilt = Store::open_in_memory().expect("open");
1117        rebuilt.rebuild(&facts2, Some("t2")).expect("rebuild");
1118
1119        let canon = |fs: FactSet| {
1120            let mut nodes = fs.nodes;
1121            nodes.sort_by(|a, b| a.key.cmp(&b.key));
1122            let mut edges: Vec<String> = fs
1123                .edges
1124                .iter()
1125                .map(|e| {
1126                    format!(
1127                        "{}\0{}\0{}\0{}\0{:?}\0{:?}",
1128                        e.kind.as_str(),
1129                        e.src,
1130                        e.dst,
1131                        e.provenance.as_str(),
1132                        e.confidence,
1133                        e.src_ref
1134                    )
1135                })
1136                .collect();
1137            edges.sort();
1138            (nodes, edges)
1139        };
1140        assert_eq!(
1141            canon(reconciled.export_factset().expect("export")),
1142            canon(rebuilt.export_factset().expect("export")),
1143            "reconcile must match a full rebuild",
1144        );
1145        assert_eq!(
1146            reconciled.sync_state().expect("state").as_deref(),
1147            Some("t2")
1148        );
1149    }
1150
1151    #[test]
1152    fn reconcile_writes_only_the_edge_delta() {
1153        // An unchanged edge must keep its row (proving reconcile does not wipe and
1154        // reinsert the whole edge set); a removed edge's row goes; a new edge's row
1155        // appears. Row identity is the SQLite `rowid` — stable unless deleted.
1156        let n = |k: &str| sample_node(k);
1157        let e =
1158            |src: &str, dst: &str| Edge::derived(src.to_owned(), dst.to_owned(), EdgeKind::Calls);
1159
1160        let mut store = Store::open_in_memory().expect("open");
1161        store
1162            .rebuild(
1163                &FactSet {
1164                    nodes: vec![n("a"), n("b"), n("c")],
1165                    edges: vec![e("a", "b"), e("b", "c")],
1166                },
1167                None,
1168            )
1169            .expect("rebuild");
1170
1171        // Map (src_key, dst_key) → rowid via the private connection.
1172        let rowids = |store: &Store| -> std::collections::HashMap<(String, String), i64> {
1173            let mut stmt = store
1174                .conn
1175                .prepare(
1176                    "SELECT ns.key, nd.key, e.rowid FROM edges e \
1177                     JOIN nodes ns ON ns.id = e.src JOIN nodes nd ON nd.id = e.dst",
1178                )
1179                .expect("prepare");
1180            stmt.query_map([], |r| {
1181                Ok((
1182                    (r.get::<_, String>(0)?, r.get::<_, String>(1)?),
1183                    r.get::<_, i64>(2)?,
1184                ))
1185            })
1186            .expect("query")
1187            .map(Result::unwrap)
1188            .collect()
1189        };
1190
1191        let before = rowids(&store);
1192        let ab_rowid = before[&("a".to_owned(), "b".to_owned())];
1193
1194        // Keep a->b, drop b->c, add a->c.
1195        store
1196            .reconcile(
1197                &FactSet {
1198                    nodes: vec![n("a"), n("b"), n("c")],
1199                    edges: vec![e("a", "b"), e("a", "c")],
1200                },
1201                None,
1202            )
1203            .expect("reconcile");
1204
1205        let after = rowids(&store);
1206        assert_eq!(
1207            after.get(&("a".to_owned(), "b".to_owned())),
1208            Some(&ab_rowid),
1209            "the unchanged edge keeps its row (not rewritten)"
1210        );
1211        assert!(
1212            !after.contains_key(&("b".to_owned(), "c".to_owned())),
1213            "the removed edge's row is gone"
1214        );
1215        assert!(
1216            after.contains_key(&("a".to_owned(), "c".to_owned())),
1217            "the added edge has a new row"
1218        );
1219    }
1220
1221    #[test]
1222    fn reconcile_is_history_independent_for_edge_queries() {
1223        // The whole point of the edge delta: it must be invisible above storage.
1224        // A store reached by rebuild(f1)+reconcile(f2) has different edge row ids
1225        // than a cold rebuild at f2, yet every edge query must return byte-for-byte
1226        // the same result — order included — because the queries are content-ordered.
1227        let n = |k: &str| sample_node(k);
1228        let d =
1229            |src: &str, dst: &str| Edge::derived(src.to_owned(), dst.to_owned(), EdgeKind::Calls);
1230        let inf = |src: &str, dst: &str, c: f64| {
1231            Edge::inferred(src.to_owned(), dst.to_owned(), EdgeKind::Related, c)
1232        };
1233        let f1 = FactSet {
1234            nodes: vec![n("a"), n("b"), n("c")],
1235            edges: vec![d("a", "b"), d("b", "c"), inf("a", "c", 0.7)],
1236        };
1237        let f2 = FactSet {
1238            nodes: vec![n("a"), n("b"), n("d")],
1239            edges: vec![d("a", "b"), d("a", "d"), inf("a", "d", 0.9)],
1240        };
1241
1242        let mut incremental = Store::open_in_memory().expect("open");
1243        incremental.rebuild(&f1, None).expect("rebuild");
1244        incremental.reconcile(&f2, None).expect("reconcile");
1245        let mut cold = Store::open_in_memory().expect("open");
1246        cold.rebuild(&f2, None).expect("rebuild");
1247
1248        // Project to all fields so the comparison covers order *and* content.
1249        let proj = |es: Vec<Edge>| -> Vec<String> {
1250            es.into_iter()
1251                .map(|e| {
1252                    format!(
1253                        "{}|{}|{}|{}|{:?}|{:?}",
1254                        e.src,
1255                        e.dst,
1256                        e.kind.as_str(),
1257                        e.provenance.as_str(),
1258                        e.confidence,
1259                        e.src_ref
1260                    )
1261                })
1262                .collect()
1263        };
1264
1265        for key in ["a", "b", "d"] {
1266            assert_eq!(
1267                proj(incremental.edges_from(key).expect("from")),
1268                proj(cold.edges_from(key).expect("from")),
1269                "edges_from({key}) must match a cold rebuild"
1270            );
1271            assert_eq!(
1272                proj(incremental.edges_to(key).expect("to")),
1273                proj(cold.edges_to(key).expect("to")),
1274                "edges_to({key}) must match a cold rebuild"
1275            );
1276        }
1277        assert_eq!(
1278            proj(incremental.all_edges().expect("all")),
1279            proj(cold.all_edges().expect("all")),
1280            "all_edges must match a cold rebuild"
1281        );
1282        for p in [Provenance::Derived, Provenance::Inferred] {
1283            assert_eq!(
1284                proj(incremental.edges_by_provenance(p).expect("prov")),
1285                proj(cold.edges_by_provenance(p).expect("prov")),
1286                "edges_by_provenance({}) must match a cold rebuild",
1287                p.as_str()
1288            );
1289        }
1290    }
1291
1292    #[test]
1293    fn reconcile_updates_confidence_on_an_unchanged_tuple() {
1294        // A change to *only* an edge's confidence — same (src, dst, kind,
1295        // provenance) — must still be applied. Edge identity includes confidence,
1296        // so it is delete+add (matching a full rebuild), not the insert-time
1297        // `DO NOTHING` that would leave the stale confidence in place.
1298        let n = |k: &str| sample_node(k);
1299        let inf = |c: f64| {
1300            let mut e = Edge::inferred("a".to_owned(), "b".to_owned(), EdgeKind::Related, c);
1301            e.src_ref = Some("import:demo".to_owned());
1302            e
1303        };
1304
1305        let mut store = Store::open_in_memory().expect("open");
1306        store
1307            .rebuild(
1308                &FactSet {
1309                    nodes: vec![n("a"), n("b")],
1310                    edges: vec![inf(0.5)],
1311                },
1312                None,
1313            )
1314            .expect("rebuild");
1315        store
1316            .reconcile(
1317                &FactSet {
1318                    nodes: vec![n("a"), n("b")],
1319                    edges: vec![inf(0.9)],
1320                },
1321                None,
1322            )
1323            .expect("reconcile");
1324
1325        let edges = store.edges_from("a").expect("edges");
1326        assert_eq!(edges.len(), 1);
1327        assert_eq!(
1328            edges[0].confidence,
1329            Some(0.9),
1330            "confidence updated, not left stale"
1331        );
1332    }
1333
1334    #[test]
1335    fn edge_identity_does_not_collide_across_field_boundaries() {
1336        // Node keys embed git paths, which may contain any byte — including the
1337        // unit separator (`\x1f`). A delimiter-joined identity would map these two
1338        // distinct edges to the same string (`a\x1fb\x1fc\x1f…`); the tuple identity
1339        // must keep them apart, or reconcile would drop one edge as a "duplicate".
1340        let e1 = super::edge_identity(&Edge::derived(
1341            "a\u{1f}b".to_owned(),
1342            "c".to_owned(),
1343            EdgeKind::Calls,
1344        ));
1345        let e2 = super::edge_identity(&Edge::derived(
1346            "a".to_owned(),
1347            "b\u{1f}c".to_owned(),
1348            EdgeKind::Calls,
1349        ));
1350        assert_ne!(
1351            e1, e2,
1352            "control chars in a key must not collapse identities"
1353        );
1354
1355        // A confidence-only difference (same tuple otherwise) also stays distinct,
1356        // and `None` (derived) never equals `Some(0.0)`.
1357        let derived = super::edge_identity(&Edge::derived(
1358            "a".to_owned(),
1359            "b".to_owned(),
1360            EdgeKind::Related,
1361        ));
1362        let inferred0 = super::edge_identity(&Edge::inferred(
1363            "a".to_owned(),
1364            "b".to_owned(),
1365            EdgeKind::Related,
1366            0.0,
1367        ));
1368        assert_ne!(derived, inferred0, "None vs Some(0.0) confidence differ");
1369    }
1370
1371    #[test]
1372    fn open_in_memory_applies_schema() {
1373        let store = Store::open_in_memory().expect("open");
1374        assert_eq!(store.node_count().expect("count"), 0);
1375        assert_eq!(store.schema_version().expect("version"), 7);
1376    }
1377
1378    #[test]
1379    fn upsert_and_get_round_trips_all_fields() {
1380        let store = Store::open_in_memory().expect("open");
1381        let node = sample_node("sym:rust:src/lib.rs#sample");
1382        store.upsert_node(&node).expect("upsert");
1383        let got = store.get_node(&node.key).expect("get").expect("present");
1384        assert_eq!(got, node);
1385    }
1386
1387    #[test]
1388    fn upsert_updates_in_place() {
1389        let store = Store::open_in_memory().expect("open");
1390        let mut node = sample_node("k");
1391        store.upsert_node(&node).expect("insert");
1392        node.name = "renamed".to_owned();
1393        node.kind = NodeKind::Struct;
1394        store.upsert_node(&node).expect("update");
1395        assert_eq!(store.node_count().expect("count"), 1);
1396        let got = store.get_node("k").expect("get").expect("present");
1397        assert_eq!(got.name, "renamed");
1398        assert_eq!(got.kind, NodeKind::Struct);
1399    }
1400
1401    #[test]
1402    fn edge_with_unknown_endpoint_is_rejected() {
1403        let store = Store::open_in_memory().expect("open");
1404        store
1405            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
1406            .expect("a");
1407        let edge = Edge::derived("a", "missing", EdgeKind::Calls);
1408        let err = store.insert_edge(&edge).expect_err("should reject");
1409        assert!(matches!(err, super::StoreError::UnknownNode(k) if k == "missing"));
1410    }
1411
1412    #[test]
1413    fn inferred_edge_requires_confidence() {
1414        let store = Store::open_in_memory().expect("open");
1415        store
1416            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
1417            .expect("a");
1418        store
1419            .upsert_node(&Node::new("b", NodeKind::Fn, "b"))
1420            .expect("b");
1421        // Hand-build an inferred edge with no confidence to violate the invariant.
1422        let bad = Edge {
1423            src: "a".to_owned(),
1424            dst: "b".to_owned(),
1425            kind: EdgeKind::References,
1426            provenance: Provenance::Inferred,
1427            confidence: None,
1428            src_ref: None,
1429        };
1430        assert!(matches!(
1431            store.insert_edge(&bad).expect_err("reject"),
1432            super::StoreError::InvalidEdge(_)
1433        ));
1434    }
1435
1436    #[test]
1437    fn apply_factset_is_atomic() {
1438        let mut store = Store::open_in_memory().expect("open");
1439        // Second edge references a missing node, so the whole set must roll back.
1440        let facts = FactSet::new()
1441            .with_node(Node::new("a", NodeKind::Fn, "a"))
1442            .with_node(Node::new("b", NodeKind::Fn, "b"))
1443            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
1444            .with_edge(Edge::derived("a", "ghost", EdgeKind::Calls));
1445        assert!(store.apply_factset(&facts).is_err());
1446        assert_eq!(store.node_count().expect("count"), 0, "rolled back");
1447        assert_eq!(store.edge_count().expect("count"), 0, "rolled back");
1448    }
1449
1450    #[test]
1451    fn neighbors_and_provenance_queries() {
1452        let mut store = Store::open_in_memory().expect("open");
1453        let facts = FactSet::new()
1454            .with_node(Node::new("a", NodeKind::Fn, "a"))
1455            .with_node(Node::new("b", NodeKind::Fn, "b"))
1456            .with_node(Node::new("c", NodeKind::Fn, "c"))
1457            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
1458            .with_edge(Edge::inferred("a", "c", EdgeKind::References, 0.5));
1459        store.apply_factset(&facts).expect("apply");
1460
1461        let out = store.neighbors("a", Direction::Outgoing).expect("out");
1462        let mut keys: Vec<_> = out.iter().map(|n| n.key.clone()).collect();
1463        keys.sort();
1464        assert_eq!(keys, ["b", "c"]);
1465
1466        assert!(
1467            store
1468                .neighbors("b", Direction::Outgoing)
1469                .expect("b out")
1470                .is_empty()
1471        );
1472        assert_eq!(
1473            store
1474                .neighbors("b", Direction::Incoming)
1475                .expect("b in")
1476                .len(),
1477            1
1478        );
1479
1480        let inferred = store
1481            .edges_by_provenance(Provenance::Inferred)
1482            .expect("inf");
1483        assert_eq!(inferred.len(), 1);
1484        assert_eq!(inferred[0].confidence, Some(0.5));
1485    }
1486
1487    #[test]
1488    fn neighbors_of_absent_node_is_empty() {
1489        let store = Store::open_in_memory().expect("open");
1490        assert!(
1491            store
1492                .neighbors("nope", Direction::Both)
1493                .expect("q")
1494                .is_empty()
1495        );
1496    }
1497
1498    #[test]
1499    fn get_missing_node_is_none() {
1500        let store = Store::open_in_memory().expect("open");
1501        assert!(store.get_node("absent").expect("get").is_none());
1502    }
1503
1504    #[test]
1505    fn nodes_by_kind_and_edges_to() {
1506        let mut store = Store::open_in_memory().expect("open");
1507        let facts = FactSet::new()
1508            .with_node(Node::new("f1", NodeKind::Fn, "f1"))
1509            .with_node(Node::new("f2", NodeKind::Fn, "f2"))
1510            .with_node(Node::new("s1", NodeKind::Struct, "s1"))
1511            .with_edge(Edge::derived("f1", "s1", EdgeKind::References))
1512            .with_edge(Edge::derived("f2", "s1", EdgeKind::References));
1513        store.apply_factset(&facts).expect("apply");
1514
1515        let fns = store.nodes_by_kind(&NodeKind::Fn).expect("fns");
1516        assert_eq!(
1517            fns.iter().map(|n| n.key.as_str()).collect::<Vec<_>>(),
1518            ["f1", "f2"]
1519        );
1520        assert!(
1521            store
1522                .nodes_by_kind(&NodeKind::Enum)
1523                .expect("enums")
1524                .is_empty()
1525        );
1526
1527        let into_s1 = store.edges_to("s1").expect("edges_to");
1528        assert_eq!(into_s1.len(), 2);
1529        assert!(into_s1.iter().all(|e| e.dst == "s1"));
1530    }
1531
1532    #[test]
1533    fn open_persists_across_reopen() {
1534        let path =
1535            std::env::temp_dir().join(format!("roteiro-open-test-{}.db", std::process::id()));
1536        std::fs::remove_file(&path).ok();
1537        {
1538            let store = Store::open(&path).expect("open");
1539            store
1540                .upsert_node(&sample_node("persisted"))
1541                .expect("upsert");
1542        }
1543        {
1544            let store = Store::open(&path).expect("reopen");
1545            assert_eq!(store.node_count().expect("count"), 1);
1546            assert_eq!(store.schema_version().expect("version"), 7);
1547            assert!(store.get_node("persisted").expect("get").is_some());
1548        }
1549        std::fs::remove_file(&path).expect("cleanup");
1550    }
1551
1552    fn graphify_layer(dst: &str) -> FactSet {
1553        FactSet::new()
1554            .with_node(Node::new("graphify:doc1", NodeKind::Doc, "Doc 1"))
1555            .with_edge({
1556                let mut e = Edge::inferred("graphify:doc1", dst, EdgeKind::References, 0.9);
1557                e.src_ref = Some("import:graphify".to_owned());
1558                e
1559            })
1560    }
1561
1562    /// A persisted import layer is re-applied after a `rebuild` wipes the graph,
1563    /// so imported facts survive a code-changing sync.
1564    #[test]
1565    fn imports_survive_rebuild() {
1566        let mut store = Store::open_in_memory().expect("open");
1567        let derived = FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a.rs"));
1568        store.rebuild(&derived, Some("tree1")).expect("rebuild");
1569
1570        // apply_import_layer applies to the live graph and persists in one step.
1571        let applied = store
1572            .apply_import_layer("import:graphify", &graphify_layer("file:a.rs"))
1573            .expect("apply import");
1574        assert_eq!(applied.edges_applied, 1);
1575        assert_eq!(applied.edges_pruned, 0);
1576        assert_eq!(store.import_refs().expect("refs"), vec!["import:graphify"]);
1577
1578        // Simulate a code-changing sync: the derived graph is rebuilt (still has
1579        // file:a.rs), which drops the imported doc + edge from the live graph.
1580        store.rebuild(&derived, Some("tree2")).expect("rebuild2");
1581        assert!(store.get_node("graphify:doc1").expect("get").is_none());
1582
1583        // Re-applying imports restores them; nothing is pruned (target present).
1584        let applied = store.reapply_imports().expect("reapply");
1585        assert_eq!(applied.layers, 1);
1586        assert_eq!(applied.nodes, 1);
1587        assert_eq!(applied.edges_applied, 1);
1588        assert_eq!(applied.edges_pruned, 0);
1589        assert!(store.get_node("graphify:doc1").expect("get").is_some());
1590        assert_eq!(store.edges_from("graphify:doc1").expect("edges").len(), 1);
1591    }
1592
1593    /// When a sync removes an edge's target (e.g. a deleted file), re-applying
1594    /// **prunes** that stale cross-reference from the persisted layer — it is not
1595    /// kept and retried forever. The import node itself is preserved.
1596    #[test]
1597    fn reapply_prunes_stale_cross_references() {
1598        let mut store = Store::open_in_memory().expect("open");
1599        let derived = FactSet::new().with_node(Node::new("file:gone.rs", NodeKind::File, "g"));
1600        store.rebuild(&derived, Some("t1")).expect("rebuild");
1601        store
1602            .apply_import_layer("import:graphify", &graphify_layer("file:gone.rs"))
1603            .expect("import");
1604
1605        // Code-changing sync: file:gone.rs is deleted from the derived graph.
1606        store
1607            .rebuild(&FactSet::new(), Some("t2"))
1608            .expect("rebuild2");
1609        let applied = store.reapply_imports().expect("reapply");
1610        assert_eq!(applied.nodes, 1);
1611        assert_eq!(applied.edges_applied, 0);
1612        assert_eq!(
1613            applied.edges_pruned, 1,
1614            "the edge to the deleted file is pruned"
1615        );
1616        assert!(store.get_node("graphify:doc1").expect("get").is_some());
1617
1618        // The prune is durable: a second reapply finds nothing left to prune,
1619        // proving the stale edge was removed from the persisted layer.
1620        let again = store.reapply_imports().expect("reapply2");
1621        assert_eq!(again.edges_applied, 0);
1622        assert_eq!(again.edges_pruned, 0, "already pruned; not retried");
1623    }
1624
1625    /// `apply_import_layer` validates on import: a dangling edge in the incoming
1626    /// layer is dropped and never persisted.
1627    #[test]
1628    fn apply_import_layer_prunes_on_import() {
1629        let mut store = Store::open_in_memory().expect("open");
1630        let present = || FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a"));
1631        store.rebuild(&present(), Some("t")).expect("rebuild");
1632
1633        let layer = graphify_layer("file:a.rs").with_edge({
1634            // Points at a file that does not exist → pruned on import.
1635            let mut e = Edge::inferred("graphify:doc1", "file:ghost.rs", EdgeKind::References, 0.9);
1636            e.src_ref = Some("import:graphify".to_owned());
1637            e
1638        });
1639        let applied = store
1640            .apply_import_layer("import:graphify", &layer)
1641            .expect("import");
1642        assert_eq!(applied.edges_applied, 1);
1643        assert_eq!(applied.edges_pruned, 1);
1644
1645        // A rebuild + reapply confirms only the valid edge was persisted.
1646        store.rebuild(&present(), Some("t2")).expect("rebuild2");
1647        let re = store.reapply_imports().expect("reapply");
1648        assert_eq!(re.edges_applied, 1);
1649        assert_eq!(re.edges_pruned, 0, "ghost edge was not persisted");
1650    }
1651
1652    #[test]
1653    fn legacy_import_layer_nodes_are_retagged_non_derived() {
1654        // A layer persisted before nodes carried provenance: its node objects have
1655        // no `provenance` field, so serde defaults them to Derived. On reapply the
1656        // store must repair them — a Graphify node to Inferred, a lat node to
1657        // Authored — so a later derived-only sync never mistakes them for derived.
1658        let mut store = Store::open_in_memory().expect("open");
1659        let legacy = r#"{"nodes":[
1660            {"key":"graphify:doc1","kind":"doc","name":"d","path":null,"lang":null,"blob_hash":null,"span":null,"meta":null},
1661            {"key":"lat:lat.md/a.md","kind":"doc","name":"a","path":null,"lang":null,"blob_hash":null,"span":null,"meta":null}
1662        ],"edges":[]}"#;
1663        store
1664            .conn
1665            .execute(
1666                "INSERT INTO imports (src_ref, facts) VALUES ('import:legacy', ?1)",
1667                [legacy],
1668            )
1669            .expect("seed legacy import row");
1670
1671        store.reapply_imports().expect("reapply");
1672
1673        let g = store
1674            .get_node("graphify:doc1")
1675            .expect("get")
1676            .expect("graphify node");
1677        assert_eq!(
1678            g.provenance,
1679            Provenance::Inferred,
1680            "graphify import node repaired to inferred"
1681        );
1682        let l = store
1683            .get_node("lat:lat.md/a.md")
1684            .expect("get")
1685            .expect("lat node");
1686        assert_eq!(
1687            l.provenance,
1688            Provenance::Authored,
1689            "lat import node repaired to authored"
1690        );
1691    }
1692
1693    /// `apply_import_layer` replaces the layer for a ref; `delete_import` removes.
1694    #[test]
1695    fn apply_import_replaces_and_delete_removes() {
1696        let mut store = Store::open_in_memory().expect("open");
1697        let a = FactSet::new().with_node(Node::new("graphify:x", NodeKind::Doc, "x"));
1698        let b = FactSet::new().with_node(Node::new("graphify:y", NodeKind::Doc, "y"));
1699        store.apply_import_layer("import:graphify", &a).expect("a");
1700        store.apply_import_layer("import:graphify", &b).expect("b");
1701        assert_eq!(
1702            store.import_refs().expect("refs").len(),
1703            1,
1704            "same ref replaced"
1705        );
1706        assert!(store.delete_import("import:graphify").expect("del"));
1707        assert!(store.import_refs().expect("refs").is_empty());
1708        assert!(!store.delete_import("import:graphify").expect("del again"));
1709    }
1710}