Skip to main content

semlith/
store.rs

1//! SQLite side of the store: file bookkeeping and chunk payloads.
2//!
3//! The vector index only ever holds `(chunk id, quantized vector)`. Everything
4//! a caller actually wants back — the text, the path, the line span — lives
5//! here and is looked up by chunk id after the search returns.
6
7use anyhow::Result;
8use rusqlite::types::Value;
9use rusqlite::{Connection, OptionalExtension, params};
10use std::path::Path;
11
12pub(crate) const SCHEMA: &str = r#"
13CREATE TABLE IF NOT EXISTS meta (
14    k TEXT PRIMARY KEY,
15    v TEXT NOT NULL
16);
17
18CREATE TABLE IF NOT EXISTS files (
19    id         INTEGER PRIMARY KEY,
20    path       TEXT NOT NULL UNIQUE,
21    hash       TEXT NOT NULL,
22    bytes      INTEGER NOT NULL,
23    indexed_at INTEGER NOT NULL
24);
25
26-- AUTOINCREMENT, not a bare rowid: a sharded index routes a chunk id to a
27-- shard by the range its file name claims, so SQLite handing a deleted row's
28-- id to a new chunk would put one id inside two shards at once.
29CREATE TABLE IF NOT EXISTS chunks (
30    id         INTEGER PRIMARY KEY AUTOINCREMENT,
31    file_id    INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
32    ord        INTEGER NOT NULL,
33    start_line INTEGER NOT NULL,
34    end_line   INTEGER NOT NULL,
35    text       TEXT NOT NULL
36);
37
38CREATE INDEX IF NOT EXISTS chunks_file_id ON chunks(file_id);
39
40-- Keyword half of the search. `content='chunks'` means FTS5 keeps only its
41-- index, not a second copy of every chunk, so the store barely grows.
42CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
43    text,
44    content='chunks',
45    content_rowid='id',
46    tokenize='unicode61'
47);
48
49-- External-content FTS5 does not track its source table by itself. These keep
50-- the two in step; a delete has to hand back the original text so FTS5 can
51-- find the terms it needs to remove. Foreign-key cascades fire them too, which
52-- is what keeps `delete_file` correct.
53CREATE TRIGGER IF NOT EXISTS chunks_fts_insert AFTER INSERT ON chunks BEGIN
54    INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
55END;
56
57CREATE TRIGGER IF NOT EXISTS chunks_fts_delete AFTER DELETE ON chunks BEGIN
58    INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES('delete', old.id, old.text);
59END;
60
61-- Structure half of the store, from 0.12.0. Additive: an older store grows
62-- these tables on its next open and they stay empty until something indexes
63-- into it, which is why the format version does not move for them. See
64-- `docs/compatibility.md`.
65--
66-- `chunk_id` is nullable on purpose. A symbol's defining line always falls
67-- inside some chunk, but a file whose chunks were rewritten between extraction
68-- and insertion would otherwise have to fail rather than record the symbol.
69CREATE TABLE IF NOT EXISTS symbols (
70    id         INTEGER PRIMARY KEY AUTOINCREMENT,
71    file_id    INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
72    chunk_id   INTEGER,
73    kind       TEXT NOT NULL,
74    name       TEXT NOT NULL,
75    qualified  TEXT NOT NULL,
76    start_line INTEGER NOT NULL,
77    end_line   INTEGER NOT NULL
78);
79
80CREATE INDEX IF NOT EXISTS symbols_file_id ON symbols(file_id);
81CREATE INDEX IF NOT EXISTS symbols_name ON symbols(name);
82
83-- What a symbol used to be, from 0.23.0. A re-index no longer simply deletes
84-- the definitions of a file it is about to rewrite: it copies them here first,
85-- stamped with the content hash the file had while they were true, so
86-- `semlith symbol` can answer "what did this look like before".
87--
88-- A separate table rather than a validity column on `symbols`, because
89-- `symbols.file_id` cascades from `files` and a re-index deletes the file row.
90-- Making the live rows outlive their file would mean loosening that foreign
91-- key, which is the one thing keeping the graph from rotting; copying them out
92-- keeps the live table exactly as strict as it was.
93--
94-- Additive and `IF NOT EXISTS`, so `format_version` does not move, for the same
95-- reason the 0.12.0 graph tables did not move it: an older binary opens the
96-- store, never looks in here, and answers exactly as before. A store written
97-- before 0.23.0 has an empty table and starts filling it at its next index
98-- pass. See `docs/compatibility.md`.
99--
100-- Nothing prunes this. History is kept whole in 0.23.0 -- a row per changed
101-- symbol per pass -- and a retention knob is a later release's problem, when
102-- growth is something measured rather than imagined.
103CREATE TABLE IF NOT EXISTS symbols_past (
104    id           INTEGER PRIMARY KEY AUTOINCREMENT,
105    path         TEXT NOT NULL,
106    kind         TEXT NOT NULL,
107    name         TEXT NOT NULL,
108    qualified    TEXT NOT NULL,
109    start_line   INTEGER NOT NULL,
110    end_line     INTEGER NOT NULL,
111    -- The content hash of the file version this definition belonged to. What
112    -- "invalid at" means: everything after this hash is a different file.
113    content_hash TEXT NOT NULL,
114    retired_at   INTEGER NOT NULL
115);
116
117CREATE INDEX IF NOT EXISTS symbols_past_name ON symbols_past(name);
118
119-- An edge is owned by the file its *source* is in: `src` is a symbol id, and
120-- the cascade above deletes an edge when its source file is re-indexed or
121-- forgotten.
122--
123-- `dst` is a symbol NAME, not an id, and that asymmetry is the point. Ids are
124-- reissued every time a file is re-extracted, so an id here would mean that
125-- re-indexing `b.rs` silently deleted every edge pointing into it from `a.rs`
126-- — the graph would rot from the one operation this release exists to make
127-- safe. A name is resolved against `symbols_name` at query time instead, so an
128-- edge is always as current as both of its ends, and an edge to something not
129-- indexed (a standard-library call) is still recorded and simply resolves to
130-- nothing.
131--
132-- `confidence` is `extracted` when an import in the source file named where the
133-- target came from, and `inferred` when the target was matched by bare name.
134-- Nothing that displays an edge may present the second as the first.
135-- `hint` is what the source text said about where `dst` lives, when it said
136-- anything: the module of a scoped call, the receiver of a method call, the
137-- object of a qualified one. Nullable, and NULL on every row an older binary
138-- wrote, which is why the format version does not move for it. It is a lead,
139-- not an address, and `edges_out` treats it as one ranking signal among
140-- several — see `resolve`.
141-- `line` is where the reference was written in the source file, from 0.16.0.
142-- Not where either symbol is defined: "where is X invoked" is a question about
143-- this column, and until it existed the closest answer was the enclosing
144-- symbol's own start line. Nullable and NULL on every row an older binary
145-- wrote, so the format version does not move for it either.
146CREATE TABLE IF NOT EXISTS edges (
147    src        INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
148    dst        TEXT NOT NULL,
149    kind       TEXT NOT NULL,
150    confidence TEXT NOT NULL,
151    hint       TEXT,
152    line       INTEGER
153);
154
155CREATE INDEX IF NOT EXISTS edges_src ON edges(src);
156CREATE INDEX IF NOT EXISTS edges_dst ON edges(dst);
157
158-- Images, from 0.13.0. One row per indexed image file, with the pixel size the
159-- Search and Files pages show in place of a line range. The vector itself is in
160-- the store's second index, under `images/`, at CLIP's 512 dimensions.
161--
162-- Additive and `IF NOT EXISTS`, so `format_version` does not move: an older
163-- binary opens the store, never looks in here, and searches its text exactly as
164-- before. Same reasoning `docs/compatibility.md` records for the graph tables.
165--
166-- `id` is AUTOINCREMENT for the same reason chunks are: the id addresses a
167-- vector in a sharded index, and SQLite reissuing a deleted row's id would put
168-- one id inside two shards at once.
169CREATE TABLE IF NOT EXISTS images (
170    id      INTEGER PRIMARY KEY AUTOINCREMENT,
171    file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
172    width   INTEGER NOT NULL,
173    height  INTEGER NOT NULL
174);
175
176CREATE INDEX IF NOT EXISTS images_file_id ON images(file_id);
177
178-- The retrieval ledger, from 0.12.0. Additive and empty unless recording is
179-- switched on, which it is not by default.
180--
181-- `prev` is the hash of the row before it and `hash` covers this row's own
182-- fields plus `prev`, so the table is a chain: editing or deleting a row
183-- breaks every hash after it and `semlith ledger` can say so. That is what
184-- makes this an audit record rather than a log file, and it costs one blake3
185-- of a short string per query.
186--
187-- `excerpt_tokens` is what the agent actually read. `whole_file_tokens` is
188-- what reading those files whole would have cost — the honest denominator for
189-- a savings number, measured rather than claimed.
190CREATE TABLE IF NOT EXISTS retrievals (
191    id                INTEGER PRIMARY KEY AUTOINCREMENT,
192    at                INTEGER NOT NULL,
193    client            TEXT NOT NULL,
194    query             TEXT NOT NULL,
195    hits              INTEGER NOT NULL,
196    micros            INTEGER NOT NULL,
197    excerpt_tokens    INTEGER NOT NULL,
198    whole_file_tokens INTEGER NOT NULL,
199    prev              TEXT NOT NULL,
200    hash              TEXT NOT NULL
201);
202
203CREATE INDEX IF NOT EXISTS retrievals_at ON retrievals(at);
204"#;
205
206/// A chunk row joined with its file's path — what a search hit resolves to.
207#[derive(Debug, Clone)]
208pub struct ChunkRow {
209    pub id: i64,
210    pub path: String,
211    pub ord: i64,
212    pub start_line: u32,
213    pub end_line: u32,
214    pub text: String,
215}
216
217pub fn open(path: &Path) -> Result<Connection> {
218    let db = Connection::open(path)?;
219    db.pragma_update(None, "journal_mode", "WAL")?;
220    db.pragma_update(None, "synchronous", "NORMAL")?;
221    db.pragma_update(None, "foreign_keys", "ON")?;
222    defensive(&db)?;
223    db.execute_batch(SCHEMA)?;
224    add_columns(&db)?;
225    check_format(&db)?;
226    backfill_fts(&db)?;
227    // Everything above is the schema this binary needs in place before the
228    // store is usable at all; from here the connection writes only when a
229    // writer asks.
230    read_only(&db, true)?;
231    Ok(db)
232}
233
234/// Treat the file as data rather than as a program.
235///
236/// A SQLite file is a schema as well as rows, and a schema can carry views,
237/// triggers and generated columns that run when the database is merely opened
238/// or read. semlith opens store files it did not necessarily write — a
239/// `.semlith` that a user has trusted arrived from somewhere, and `--store`
240/// opens anything the user names — so both settings are on for every
241/// connection:
242///
243/// - `trusted_schema=OFF` stops a view or a trigger calling the functions
244///   SQLite marks as unsafe for a schema it does not trust;
245/// - `SQLITE_DBCONFIG_DEFENSIVE` refuses writes to shadow tables and to
246///   `sqlite_schema`, which is how a corrupted or crafted FTS5 index turns a
247///   read into something else.
248///
249/// Both are cheap and neither affects a query semlith itself issues, which is
250/// what `tests/measure.rs` is there to keep true.
251fn defensive(db: &Connection) -> Result<()> {
252    db.pragma_update(None, "trusted_schema", "OFF")?;
253    db.set_db_config(rusqlite::config::DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
254    Ok(())
255}
256
257/// Make this connection refuse writes until a writer says otherwise.
258///
259/// `query_only` rather than `SQLITE_OPEN_READ_ONLY`, because semlith opens one
260/// connection per store and writes through the same handle it reads through:
261/// a read-only open would mean two connections per store and a second answer to
262/// "who holds the writer", which is the one thing the store's design keeps
263/// singular. The property is the same from the file's point of view — a
264/// connection in this state cannot write, whatever it is asked to do — and it
265/// is lifted only by [`Writing`], which the three write paths take.
266pub fn read_only(db: &Connection, on: bool) -> Result<()> {
267    db.pragma_update(None, "query_only", if on { "ON" } else { "OFF" })?;
268    Ok(())
269}
270
271/// Permission to write, given back when it goes out of scope.
272///
273/// Taken by the three methods that write — indexing, forgetting, and recording
274/// a retrieval in the ledger — so every other path through the store is a path
275/// SQLite itself will refuse to write from.
276pub struct Writing<'a>(&'a Connection);
277
278impl<'a> Writing<'a> {
279    pub fn begin(db: &'a Connection) -> Result<Self> {
280        read_only(db, false)?;
281        Ok(Self(db))
282    }
283}
284
285impl Drop for Writing<'_> {
286    fn drop(&mut self) {
287        // Best effort on purpose: a connection that cannot be put back into
288        // read-only mode is one whose next statement will fail anyway, and a
289        // panic in a destructor would replace a clear error with a confusing
290        // one.
291        let _ = read_only(self.0, true);
292    }
293}
294
295/// The store layout this binary understands.
296///
297/// Written into a store when one is created, and absent from every store
298/// written before 0.6.0 — which is what makes 1 the right reading of an absent
299/// key rather than an unknown.
300///
301/// 1 is a single `index.tv`. 2 is a directory of shards, written by 0.7.0 and
302/// later; see [`crate::index`]. 3 is a store written by 0.22.0 and later,
303/// whose Markdown chunks are cut at their headings and carry the heading path
304/// as embedding context. 4 is a store written by 0.25.0 and later, whose code
305/// chunks carry the definition they sit inside — its signature and the first
306/// line of its doc comment — as embedding context. A binary understands every
307/// format up to its own, so this one reads all four and creates the newest.
308pub const FORMAT_VERSION: u32 = 4;
309
310/// The first format that keeps its vectors in shards.
311pub const SHARDED_FORMAT: u32 = 2;
312
313/// The first format whose code chunks embed the definition they sit inside.
314///
315/// A store below this holds code chunks embedded from their own text alone.
316/// They still answer — the row, the span and the bytes are identical, and only
317/// what the model was shown differs — but a store half embedded one way and
318/// half the other ranks its own files against each other unevenly, so the
319/// first full index pass under 0.25.0 re-embeds everything it walks. `semlith
320/// stats` says which rule a store is on until it has.
321pub const CODE_CONTEXT: u32 = 4;
322
323/// The first format whose Markdown chunks are cut at their headings.
324///
325/// A store below this holds fixed windows and still answers: a chunk is a chunk
326/// whichever rule cut it, and nothing about the row or the vector changes. What
327/// changes is where the boundaries fall, so a store that is half one rule and
328/// half the other would rank its own files against each other unevenly — which
329/// is why the first full index pass under 0.22.0 re-chunks everything it walks
330/// rather than waiting for each file to be edited. `semlith stats` says which
331/// rule a store is on until it has.
332pub const DEFINITION_CHUNKS: u32 = 3;
333
334/// Which chunking rule this store's chunks were cut by, in the words `stats`
335/// and the portal print.
336pub fn chunking(db: &Connection) -> Result<&'static str> {
337    Ok(match format(db)? {
338        v if v >= CODE_CONTEXT => "headings, and code with its definition",
339        v if v >= DEFINITION_CHUNKS => {
340            "headings; code carries no definition until it is re-indexed"
341        }
342        _ => "fixed windows",
343    })
344}
345
346/// Which layout this store's vectors are in.
347///
348/// An absent key is format 1: every store written before 0.6.0 has no key, and
349/// reading it as anything else would refuse the whole installed base.
350pub fn format(db: &Connection) -> Result<u32> {
351    Ok(get_meta(db, FORMAT_KEY)?
352        .and_then(|v| v.parse().ok())
353        .unwrap_or(1))
354}
355
356pub const FORMAT_KEY: &str = "format_version";
357
358/// Refuse a store a later semlith wrote, rather than misreading it.
359///
360/// Nothing is written here. A store from before this key existed is format 1
361/// and stays exactly as it is: a read command that quietly rewrote the store
362/// it was only asked to search would be a worse surprise than the one this
363/// guards against.
364fn check_format(db: &Connection) -> Result<()> {
365    let Some(recorded) = get_meta(db, FORMAT_KEY)? else {
366        return Ok(());
367    };
368    let found: u32 = recorded.parse().map_err(|_| {
369        anyhow::anyhow!("store records {FORMAT_KEY} {recorded:?}, which is not a version number")
370    })?;
371    if found > FORMAT_VERSION {
372        anyhow::bail!(
373            "store is format {found}, and semlith {} understands format {FORMAT_VERSION}; \
374             upgrade semlith to read this store",
375            env!("CARGO_PKG_VERSION"),
376        );
377    }
378    Ok(())
379}
380
381/// Meta key recording that the keyword index has been built for this store.
382///
383/// Counting rows in `chunks_fts` cannot answer this. An external-content FTS5
384/// table reads through to its content table, so `COUNT(*)` returns the number
385/// of chunks whether or not a single term has been indexed — a guard built on
386/// it would skip the backfill on exactly the stores that need it.
387const FTS_BUILT: &str = "fts_built";
388
389/// Populate the keyword index for a store written before it existed.
390///
391/// A 0.1.0 store has chunks but no FTS index, and the triggers only fire on new
392/// writes. Rebuilding from the text already in SQLite costs no embedding and
393/// leaves the vectors untouched.
394/// Columns added to tables that already exist in a store written by an older
395/// binary.
396///
397/// `CREATE TABLE IF NOT EXISTS` is how every table here arrives, and it does
398/// nothing at all to a table that is already there — so a column added to the
399/// schema above reaches a fresh store and no other. This is the other half:
400/// one `ALTER TABLE ADD COLUMN` per column, run on every open, skipped when
401/// the column is present.
402///
403/// Every column here must be nullable with no default, which is what makes the
404/// operation an O(1) catalogue edit rather than a table rewrite, and what lets
405/// an older binary keep reading the store afterwards: it selects by name and
406/// never sees them. That is the whole reason `format_version` does not move —
407/// the same reasoning `docs/compatibility.md` records for the graph tables.
408fn add_columns(db: &Connection) -> Result<()> {
409    const ADDITIONS: [(&str, &str, &str); 8] = [
410        ("edges", "hint", "TEXT"),
411        // 0.16.0: the line the reference was written on.
412        ("edges", "line", "INTEGER"),
413        // The ledger's 0.15.0 columns. `session` groups the retrievals of one
414        // agent conversation, `tool` says which tool was asked, `stale_hits`
415        // counts the answers that came from a file edited since it was
416        // indexed, and `tokenizer` names what counted the two token figures so
417        // rows counted two different ways are never summed together.
418        ("retrievals", "session", "TEXT"),
419        ("retrievals", "tool", "TEXT"),
420        ("retrievals", "stale_hits", "INTEGER"),
421        ("retrievals", "tokenizer", "TEXT"),
422        // 0.20.2: the id one retrieval shares across every store that
423        // contributed to it. A search over six stores used to write six rows
424        // and the Ledger page counted six retrievals, so every figure on it
425        // scaled with how many stores happened to be open. The rows stay per
426        // store — that is what keeps each store's chain verifiable and its
427        // token counts its own — and this is what makes them one retrieval
428        // again when they are counted.
429        ("retrievals", "query_id", "TEXT"),
430        // 0.25.0: what the parser made of this file — "parsed", "timeout" or
431        // "none" for a language that carries no grammar. Without it a file
432        // with no definitions and a file the parser gave up on look the same
433        // in the per-language coverage table, which is the one question that
434        // table exists to answer.
435        ("files", "graph", "TEXT"),
436    ];
437    for (table, column, kind) in ADDITIONS {
438        if has_column(db, table, column)? {
439            continue;
440        }
441        db.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {kind};"))?;
442    }
443    Ok(())
444}
445
446fn has_column(db: &Connection, table: &str, column: &str) -> Result<bool> {
447    let mut stmt = db.prepare(&format!("PRAGMA table_info({table})"))?;
448    let mut rows = stmt.query([])?;
449    while let Some(row) = rows.next()? {
450        let name: String = row.get(1)?;
451        if name == column {
452            return Ok(true);
453        }
454    }
455    Ok(false)
456}
457
458fn backfill_fts(db: &Connection) -> Result<()> {
459    if get_meta(db, FTS_BUILT)?.is_some() {
460        return Ok(());
461    }
462    // FTS5's own command for external content: discard the index and rebuild
463    // it from the content table.
464    db.execute_batch("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');")?;
465    set_meta(db, FTS_BUILT, "1")?;
466    Ok(())
467}
468
469/// `AND`ed groups of `OR`ed `GLOB` patterns as a SQL fragment, plus the values
470/// to bind. An empty group list yields `1` — a predicate that selects
471/// everything, so callers need no special case.
472///
473/// Patterns are matched against `lower(files.path)`: filtering is
474/// case-insensitive, because `README.MD` and `readme.md` are the same file to
475/// anyone typing `--ext md`, and no filesystem this runs on treats a path's
476/// case as load-bearing.
477fn glob_predicate(groups: &[Vec<String>]) -> (String, Vec<String>) {
478    let mut binds = Vec::new();
479    let mut clauses = Vec::new();
480    for group in groups {
481        // A pattern marked with a leading `!` by `filter::anchor` excludes.
482        // Exclusions apply after the inclusions of their own kind, which is
483        // what keeps them inside this group rather than becoming a group of
484        // their own: `--path 'src/**' --path '!src/vendor/**'` is one
485        // requirement, not two intersecting ones.
486        let (excluded, included): (Vec<&String>, Vec<&String>) =
487            group.iter().partition(|p| p.starts_with('!'));
488        let placeholder = format!("{GLOB_PATH} GLOB ?");
489        let ors = vec![placeholder.clone(); included.len()].join(" OR ");
490        let nots = vec![placeholder; excluded.len()].join(" OR ");
491
492        // Binds are positional, so they are pushed in the order the clause
493        // below writes their placeholders — inclusions first — rather than in
494        // the order the user typed them.
495        binds.extend(included.into_iter().cloned());
496        binds.extend(excluded.into_iter().map(|p| p[1..].to_string()));
497
498        clauses.push(match (ors.is_empty(), nots.is_empty()) {
499            (true, true) => continue,
500            (false, true) => format!("({ors})"),
501            // Exclusions with nothing beside them mean everything except.
502            (true, false) => format!("(NOT ({nots}))"),
503            (false, false) => format!("(({ors}) AND NOT ({nots}))"),
504        });
505    }
506    if clauses.is_empty() {
507        return ("1".to_string(), binds);
508    }
509    (clauses.join(" AND "), binds)
510}
511
512/// Ids of every chunk whose file matches `groups`.
513///
514/// This is the single source of the subset. The vector index gets these ids as
515/// an allowlist and [`keyword_search`] applies the same predicate, so the two
516/// halves of a hybrid search can never rank a chunk the other was forbidden to
517/// see.
518pub fn filtered_chunk_ids(db: &Connection, groups: &[Vec<String>]) -> Result<Vec<u64>> {
519    let (predicate, binds) = glob_predicate(groups);
520    let sql =
521        format!("SELECT c.id FROM chunks c JOIN files f ON f.id = c.file_id WHERE {predicate}");
522    let mut stmt = db.prepare(&sql)?;
523    let args = binds.into_iter().map(Value::Text);
524    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| r.get::<_, i64>(0))?;
525    Ok(rows
526        .collect::<Result<Vec<i64>, _>>()?
527        .into_iter()
528        .map(|i| i as u64)
529        .collect())
530}
531
532/// How many indexed files `groups` selects.
533///
534/// Zero means the filter itself is wrong — a typo in a glob, a subsystem that
535/// was never indexed — which is a different thing to tell a user than "the
536/// corpus does not contain this".
537pub fn matching_files(db: &Connection, groups: &[Vec<String>]) -> Result<i64> {
538    let (predicate, binds) = glob_predicate(groups);
539    let sql = format!("SELECT COUNT(*) FROM files f WHERE {predicate}");
540    let args = binds.into_iter().map(Value::Text);
541    Ok(db.query_row(&sql, rusqlite::params_from_iter(args), |r| r.get(0))?)
542}
543
544/// The keyword statement for a search with no filter.
545///
546/// Byte-identical to the one 0.2.0 issued: no join to `chunks` and `files`, so
547/// a query that uses no filter pays nothing for the filtering feature. A test
548/// pins the text, because the cheapest way to regress the common path is to
549/// "tidy" the two branches into one.
550const UNFILTERED_SQL: &str =
551    "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?";
552
553/// Chunk ids matching `query` as keywords, best first, restricted to files
554/// matching `groups`.
555///
556/// The query is reduced to bare terms before it reaches FTS5. A raw query would
557/// be parsed as FTS5 syntax, where a stray quote or `*` is a syntax error and a
558/// bare `AND` is an operator — a search for "index AND search" would silently
559/// mean something the user did not type, and a search for `foo(` would fail
560/// outright.
561pub fn keyword_search(
562    db: &Connection,
563    query: &str,
564    limit: usize,
565    groups: &[Vec<String>],
566) -> Result<Vec<u64>> {
567    let terms: Vec<String> = query
568        .split(|c: char| !c.is_alphanumeric() && c != '_')
569        .filter(|t| !t.is_empty())
570        .map(|t| format!("\"{t}\""))
571        .collect();
572    if terms.is_empty() {
573        return Ok(Vec::new());
574    }
575    let match_expr = terms.join(" OR ");
576
577    let (sql, mut args) = if groups.is_empty() {
578        (UNFILTERED_SQL.to_string(), vec![Value::Text(match_expr)])
579    } else {
580        let (predicate, binds) = glob_predicate(groups);
581        let mut args = vec![Value::Text(match_expr)];
582        args.extend(binds.into_iter().map(Value::Text));
583        (
584            format!(
585                "SELECT x.rowid FROM chunks_fts x
586                 JOIN chunks c ON c.id = x.rowid
587                 JOIN files f ON f.id = c.file_id
588                 WHERE x.chunks_fts MATCH ? AND {predicate}
589                 ORDER BY x.rank LIMIT ?"
590            ),
591            args,
592        )
593    };
594    // Bound last because `LIMIT` is the final placeholder in either statement.
595    args.push(Value::Integer(limit as i64));
596
597    let mut stmt = db.prepare(&sql)?;
598    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| r.get::<_, i64>(0))?;
599    Ok(rows
600        .collect::<Result<Vec<i64>, _>>()?
601        .into_iter()
602        .map(|i| i as u64)
603        .collect())
604}
605
606pub fn get_meta(db: &Connection, k: &str) -> Result<Option<String>> {
607    Ok(db
608        .query_row("SELECT v FROM meta WHERE k = ?1", params![k], |r| r.get(0))
609        .optional()?)
610}
611
612pub fn set_meta(db: &Connection, k: &str, v: &str) -> Result<()> {
613    db.execute(
614        "INSERT INTO meta (k, v) VALUES (?1, ?2)
615         ON CONFLICT(k) DO UPDATE SET v = excluded.v",
616        params![k, v],
617    )?;
618    Ok(())
619}
620
621/// Content hash recorded for `path`, if we have indexed it before.
622pub fn file_hash(db: &Connection, path: &str) -> Result<Option<String>> {
623    Ok(db
624        .query_row(
625            "SELECT hash FROM files WHERE path = ?1",
626            params![path],
627            |r| r.get(0),
628        )
629        .optional()?)
630}
631
632/// Drop a file and its chunks, returning the chunk ids so the caller can
633/// evict them from the vector index too.
634pub fn delete_file(db: &Connection, path: &str, now: i64) -> Result<Vec<u64>> {
635    let ids: Vec<u64> = {
636        let mut stmt = db.prepare(
637            "SELECT c.id FROM chunks c JOIN files f ON f.id = c.file_id WHERE f.path = ?1",
638        )?;
639        let rows = stmt.query_map(params![path], |r| r.get::<_, i64>(0))?;
640        rows.collect::<Result<Vec<i64>, _>>()?
641            .into_iter()
642            .map(|i| i as u64)
643            .collect()
644    };
645    retire_symbols(db, path, now)?;
646    db.execute("DELETE FROM files WHERE path = ?1", params![path])?;
647    Ok(ids)
648}
649
650/// Copy a file's current definitions into `symbols_past` before the file row
651/// takes them with it.
652///
653/// Stamped with the hash the file has right now, which is the hash they were
654/// extracted from: `delete_file` runs before the new version is inserted, so
655/// `files.hash` here is still the old content's.
656///
657/// A file with no symbols copies nothing, and a store opened by a binary that
658/// never wrote this table simply has none to copy.
659fn retire_symbols(db: &Connection, path: &str, now: i64) -> Result<()> {
660    db.execute(
661        "INSERT INTO symbols_past
662             (path, kind, name, qualified, start_line, end_line, content_hash, retired_at)
663         SELECT f.path, s.kind, s.name, s.qualified, s.start_line, s.end_line, f.hash, ?2
664         FROM symbols s JOIN files f ON f.id = s.file_id
665         WHERE f.path = ?1",
666        params![path, now],
667    )?;
668    Ok(())
669}
670
671/// What a symbol used to be, newest first.
672///
673/// The live definitions are what `symbols_named` answers; these are the ones a
674/// re-index replaced, each with the content hash of the file version it was
675/// true for. An empty list means the store has never seen this name change --
676/// or has never re-indexed since 0.23.0, which is the same answer from the
677/// outside and is why `semlith stats` says whether the store keeps history.
678pub fn symbols_past_named(db: &Connection, name: &str, limit: usize) -> Result<Vec<PastSymbol>> {
679    let mut stmt = db.prepare(
680        "SELECT path, kind, name, qualified, start_line, end_line, content_hash, retired_at
681         FROM symbols_past WHERE name = ?1 ORDER BY retired_at DESC, id DESC LIMIT ?2",
682    )?;
683    let rows = stmt.query_map(params![name, limit as i64], |r| {
684        Ok(PastSymbol {
685            path: r.get(0)?,
686            kind: r.get(1)?,
687            name: r.get(2)?,
688            qualified: r.get(3)?,
689            start_line: r.get(4)?,
690            end_line: r.get(5)?,
691            content_hash: r.get(6)?,
692            retired_at: r.get(7)?,
693            store: None,
694        })
695    })?;
696    Ok(rows.collect::<Result<Vec<_>, _>>()?)
697}
698
699/// How many definitions this store has retired, over all names.
700pub fn symbols_past_count(db: &Connection) -> Result<i64> {
701    Ok(db.query_row("SELECT COUNT(*) FROM symbols_past", [], |r| r.get(0))?)
702}
703
704/// A definition a re-index replaced.
705#[derive(Debug, Clone, serde::Serialize)]
706pub struct PastSymbol {
707    #[serde(serialize_with = "crate::serialize_plain")]
708    pub path: String,
709    pub kind: String,
710    pub name: String,
711    pub qualified: String,
712    pub start_line: u32,
713    pub end_line: u32,
714    /// The content hash of the file version this definition belonged to.
715    pub content_hash: String,
716    pub retired_at: i64,
717    /// Which store this came from, set only when more than one was searched.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    pub store: Option<String>,
720}
721
722pub fn insert_file(db: &Connection, path: &str, hash: &str, bytes: u64, now: i64) -> Result<i64> {
723    db.execute(
724        "INSERT INTO files (path, hash, bytes, indexed_at) VALUES (?1, ?2, ?3, ?4)",
725        params![path, hash, bytes as i64, now],
726    )?;
727    Ok(db.last_insert_rowid())
728}
729
730/// Record what the parser made of a file: `parsed`, `timeout`, or `none` for
731/// a language semlith carries no grammar for.
732///
733/// Written beside the file row rather than derived later, because the two
734/// failures it separates are invisible afterwards: a file whose parse expired
735/// and a file whose language has no grammar both arrive at the store with no
736/// symbols at all.
737pub fn set_file_graph(db: &Connection, file_id: i64, state: &str) -> Result<()> {
738    db.execute(
739        "UPDATE files SET graph = ?1 WHERE id = ?2",
740        params![state, file_id],
741    )?;
742    Ok(())
743}
744
745/// What the graph covers, per language, read from the rows themselves.
746#[derive(Debug, Clone, Default, serde::Serialize)]
747pub struct LanguageCoverage {
748    pub language: String,
749    /// Files of this language the store holds.
750    pub files: usize,
751    /// Files whose parse expired, so the store holds their text and none of
752    /// their structure.
753    pub parser_failed: usize,
754    pub definitions: usize,
755    /// Call edges whose target was found in the file that made the call.
756    pub extracted: usize,
757    /// Call edges whose target name has exactly one definition in the store.
758    pub resolved: usize,
759    /// Call edges whose target name has several, and nothing here says which.
760    pub ambiguous: usize,
761    /// Call edges naming something no definition in this store satisfies —
762    /// the standard library, a dependency nobody indexed, a typo.
763    pub unresolved: usize,
764}
765
766impl LanguageCoverage {
767    /// The share of this language's answerable call edges that settled on one
768    /// definition, which is the figure the README quotes for the whole store.
769    #[must_use]
770    pub fn settled_share(&self) -> usize {
771        let answerable = self.extracted + self.resolved + self.ambiguous;
772        if answerable == 0 {
773            return 0;
774        }
775        (self.extracted + self.resolved) * 100 / answerable
776    }
777}
778
779/// Per-language coverage for every language the store holds.
780///
781/// Sorted by files descending then by name, so the language that dominates a
782/// corpus is the first line a reader sees and two runs over one store print
783/// the same table.
784///
785/// The edge classes are the same four the resolver reports, decided the same
786/// way: `extracted` is stored on the edge, and an `inferred` edge is resolved,
787/// ambiguous or unresolved according to how many definitions of its target
788/// name this store holds. A second rule here would be a second answer to "what
789/// share resolves", and the README quotes this one.
790pub fn coverage_by_language(db: &Connection) -> Result<Vec<LanguageCoverage>> {
791    use std::collections::BTreeMap;
792    let mut by_language: BTreeMap<String, LanguageCoverage> = BTreeMap::new();
793    let language = |path: &str| -> String {
794        crate::graph::language_of(std::path::Path::new(path))
795            .unwrap_or("other")
796            .to_string()
797    };
798
799    let mut stmt = db.prepare("SELECT path, graph FROM files")?;
800    let rows = stmt.query_map([], |r| {
801        Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
802    })?;
803    for row in rows {
804        let (path, graph) = row?;
805        let entry = by_language.entry(language(&path)).or_default();
806        entry.files += 1;
807        if graph.as_deref() == Some("timeout") {
808            entry.parser_failed += 1;
809        }
810    }
811
812    let mut stmt = db.prepare(
813        "SELECT f.path, COUNT(*) FROM symbols s JOIN files f ON f.id = s.file_id GROUP BY f.path",
814    )?;
815    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
816    for row in rows {
817        let (path, count) = row?;
818        by_language.entry(language(&path)).or_default().definitions +=
819            usize::try_from(count).unwrap_or(0);
820    }
821
822    // One row per edge, and the definition count of its target beside it.
823    let mut stmt = db.prepare(
824        "SELECT f.path, e.confidence, (SELECT COUNT(*) FROM symbols d WHERE d.name = e.dst) \
825         FROM edges e JOIN symbols s ON s.id = e.src JOIN files f ON f.id = s.file_id \
826         WHERE e.kind = 'calls'",
827    )?;
828    let rows = stmt.query_map([], |r| {
829        Ok((
830            r.get::<_, String>(0)?,
831            r.get::<_, String>(1)?,
832            r.get::<_, i64>(2)?,
833        ))
834    })?;
835    for row in rows {
836        let (path, confidence, definitions) = row?;
837        let entry = by_language.entry(language(&path)).or_default();
838        if confidence == crate::graph::EXTRACTED {
839            entry.extracted += 1;
840        } else {
841            match definitions {
842                0 => entry.unresolved += 1,
843                1 => entry.resolved += 1,
844                _ => entry.ambiguous += 1,
845            }
846        }
847    }
848
849    let mut coverage: Vec<LanguageCoverage> = by_language
850        .into_iter()
851        .map(|(language, mut row)| {
852            row.language = language;
853            row
854        })
855        .collect();
856    coverage.sort_by(|a, b| {
857        b.files
858            .cmp(&a.files)
859            .then_with(|| a.language.cmp(&b.language))
860    });
861    Ok(coverage)
862}
863
864pub fn insert_chunk(
865    db: &Connection,
866    file_id: i64,
867    ord: usize,
868    start_line: u32,
869    end_line: u32,
870    text: &str,
871) -> Result<i64> {
872    db.execute(
873        "INSERT INTO chunks (file_id, ord, start_line, end_line, text)
874         VALUES (?1, ?2, ?3, ?4, ?5)",
875        params![file_id, ord as i64, start_line, end_line, text],
876    )?;
877    Ok(db.last_insert_rowid())
878}
879
880/// Indexed files whose path ends with `suffix`, most specific first.
881///
882/// A locate answer prints a store-relative path and a user types one, while
883/// `files.path` is absolute. Rather than teach every caller to rebuild the
884/// absolute form — which needs the store's roots and gets it wrong for a file
885/// reached through a symlink — the suffix is matched against what was actually
886/// indexed. `/` is prepended so `one.rs` cannot match `alone.rs`.
887pub fn files_ending_with(db: &Connection, suffix: &str, limit: usize) -> Result<Vec<String>> {
888    let suffix = suffix
889        .trim_start_matches(['.', '/', '\\'])
890        .replace('\\', "/");
891    let mut stmt = db.prepare(&format!(
892        "SELECT path FROM files WHERE path = ?1 OR {PATH_AS_TYPED} LIKE '%/' || ?1
893         ORDER BY LENGTH(path) LIMIT ?2",
894    ))?;
895    let rows = stmt.query_map(params![suffix, limit as i64], |r| r.get::<_, String>(0))?;
896    Ok(rows.collect::<Result<Vec<_>, _>>()?)
897}
898
899/// `files.path` with separators in the form a person types, for the comparisons
900/// that take a path from one.
901///
902/// A Windows store holds `C:\work\api\src\lock.rs` — or the verbatim form of
903/// it — and every locator, glob and `--lang` pattern is written with `/`, which
904/// is what `semlith read src/main.rs:28-40` hands back. Comparing the two
905/// without this matched nothing on that platform, which is the half of #74 that
906/// is not the prefix. On unix the column is used as it is: a backslash there is
907/// an ordinary character in a filename and rewriting it would make
908/// `a\b.rs` answer to `a/b.rs`.
909#[cfg(windows)]
910const PATH_AS_TYPED: &str = r#"replace(path, '\', '/')"#;
911#[cfg(not(windows))]
912const PATH_AS_TYPED: &str = "path";
913
914/// The same, for the queries that alias the table as `f` and lowercase it.
915#[cfg(windows)]
916const GLOB_PATH: &str = r#"replace(lower(f.path), '\', '/')"#;
917#[cfg(not(windows))]
918const GLOB_PATH: &str = "lower(f.path)";
919
920/// Every chunk of one file whose lines overlap `start..=end`, in file order.
921///
922/// The second stage of a retrieval: a locate answer says `src/store.rs:1041-1080`
923/// and this is what returns those lines and nothing else. Chunks overlap by two
924/// lines by design, so the caller stitches rather than concatenates — which is
925/// [`crate::Span::text`]'s job, not this one's.
926pub fn chunks_overlapping(
927    db: &Connection,
928    path: &str,
929    start: u32,
930    end: u32,
931) -> Result<Vec<ChunkRow>> {
932    let mut stmt = db.prepare(
933        "SELECT c.id, f.path, c.ord, c.start_line, c.end_line, c.text
934         FROM chunks c JOIN files f ON f.id = c.file_id
935         WHERE f.path = ?1 AND c.start_line <= ?2 AND c.end_line >= ?3
936         ORDER BY c.ord",
937    )?;
938    let rows = stmt.query_map(params![path, end, start], |r| {
939        Ok(ChunkRow {
940            id: r.get(0)?,
941            path: r.get(1)?,
942            ord: r.get(2)?,
943            start_line: r.get(3)?,
944            end_line: r.get(4)?,
945            text: r.get(5)?,
946        })
947    })?;
948    Ok(rows.collect::<Result<Vec<_>, _>>()?)
949}
950
951pub fn chunk(db: &Connection, id: u64) -> Result<Option<ChunkRow>> {
952    Ok(db
953        .query_row(
954            "SELECT c.id, f.path, c.ord, c.start_line, c.end_line, c.text
955             FROM chunks c JOIN files f ON f.id = c.file_id
956             WHERE c.id = ?1",
957            params![id as i64],
958            |r| {
959                Ok(ChunkRow {
960                    id: r.get(0)?,
961                    path: r.get(1)?,
962                    ord: r.get(2)?,
963                    start_line: r.get(3)?,
964                    end_line: r.get(4)?,
965                    text: r.get(5)?,
966                })
967            },
968        )
969        .optional()?)
970}
971
972/// One row per indexed file: path, bytes, chunks, the last line any chunk of
973/// it covers, and when it was last indexed.
974#[derive(Debug, Clone)]
975pub struct FileRow {
976    pub path: String,
977    pub bytes: i64,
978    pub chunks: i64,
979    pub lines: i64,
980    pub indexed_at: i64,
981}
982
983/// The column a Files listing is ordered by.
984///
985/// An enum rather than a string spliced into the SQL: the sort column arrives
986/// from a query parameter, and the only safe way to put a caller's value in an
987/// `ORDER BY` — which cannot be bound as a parameter — is to never put their
988/// bytes there at all.
989#[derive(Debug, Clone, Copy, PartialEq, Eq)]
990pub enum FileSort {
991    Path,
992    Bytes,
993    Chunks,
994    Lines,
995    Indexed,
996    /// Which store holds the row. Not a column of this table at all — it is
997    /// which database the row came out of — so it is ordered by the merge.
998    Store,
999    /// The reader that parsed the file, derived from its extension.
1000    Reader,
1001    /// The language the file was recognised as, derived from its extension.
1002    Lang,
1003}
1004
1005impl FileSort {
1006    pub fn parse(name: &str) -> Option<Self> {
1007        match name {
1008            "path" => Some(Self::Path),
1009            "bytes" => Some(Self::Bytes),
1010            "chunks" => Some(Self::Chunks),
1011            "lines" => Some(Self::Lines),
1012            "indexed" => Some(Self::Indexed),
1013            "store" => Some(Self::Store),
1014            "reader" => Some(Self::Reader),
1015            "lang" => Some(Self::Lang),
1016            _ => None,
1017        }
1018    }
1019
1020    /// Whether this ordering is over a value the database does not hold.
1021    ///
1022    /// Three of the seven columns are derived by the route from the path, or
1023    /// from which store answered. They cannot be an `ORDER BY`, so the rows
1024    /// are ordered by the merge instead — which needs every matching row
1025    /// rather than one page of each store's.
1026    pub fn derived(self) -> bool {
1027        matches!(self, Self::Store | Self::Reader | Self::Lang)
1028    }
1029
1030    fn sql(self) -> &'static str {
1031        match self {
1032            Self::Path => "f.path",
1033            Self::Bytes => "f.bytes",
1034            Self::Chunks => "COUNT(c.id)",
1035            Self::Lines => "COALESCE(MAX(c.end_line), 0)",
1036            Self::Indexed => "f.indexed_at",
1037            // The base order under a derived sort: deterministic, and the
1038            // tie-break the merge uses anyway.
1039            Self::Store | Self::Reader | Self::Lang => "f.path",
1040        }
1041    }
1042}
1043
1044/// How many indexed files match `groups`.
1045///
1046/// Separate from the rows because the Files table pages: the page is bounded
1047/// and the total is not, and returning every row to count them is what the old
1048/// silent truncation was hiding.
1049pub fn file_count(db: &Connection, groups: &[Vec<String>]) -> Result<i64> {
1050    let (predicate, binds) = glob_predicate(groups);
1051    let sql = format!("SELECT COUNT(*) FROM files f WHERE {predicate}");
1052    let mut stmt = db.prepare(&sql)?;
1053    let args = binds.into_iter().map(Value::Text);
1054    Ok(stmt.query_row(rusqlite::params_from_iter(args), |r| r.get(0))?)
1055}
1056
1057/// The first `take` matching files in the requested order.
1058///
1059/// The Files view's row, in one query. Asking per file would be one statement
1060/// per file, and a corpus is tens of thousands of them. The ordering is done
1061/// here rather than in the browser so that sorting orders the whole store
1062/// rather than the page of it that happens to be loaded — with several stores
1063/// open the caller takes `offset + limit` from each and merges, which is why
1064/// this takes a count rather than an offset.
1065pub fn file_rows(
1066    db: &Connection,
1067    groups: &[Vec<String>],
1068    sort: FileSort,
1069    desc: bool,
1070    take: i64,
1071) -> Result<Vec<FileRow>> {
1072    let (predicate, binds) = glob_predicate(groups);
1073    let direction = if desc { "DESC" } else { "ASC" };
1074    let sql = format!(
1075        "SELECT f.path, f.bytes, COUNT(c.id), COALESCE(MAX(c.end_line), 0), f.indexed_at \
1076         FROM files f LEFT JOIN chunks c ON c.file_id = f.id \
1077         WHERE {predicate} GROUP BY f.id ORDER BY {} {direction}, f.path ASC LIMIT ?",
1078        sort.sql()
1079    );
1080    let mut stmt = db.prepare(&sql)?;
1081    let mut args: Vec<Value> = binds.into_iter().map(Value::Text).collect();
1082    args.push(Value::Integer(take.max(0)));
1083    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1084        Ok(FileRow {
1085            path: r.get(0)?,
1086            bytes: r.get(1)?,
1087            chunks: r.get(2)?,
1088            lines: r.get(3)?,
1089            indexed_at: r.get(4)?,
1090        })
1091    })?;
1092    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1093}
1094
1095/// What a listing spans: the distinct file extensions in it, and the total
1096/// number of lines across every file.
1097///
1098/// Both are things the Stores and Files pages state and neither `stats()` nor
1099/// a page of rows can answer — a page knows about its own rows, and a header
1100/// that reports "9 formats" from the fifteen rows on screen is reporting the
1101/// page rather than the store.
1102#[derive(Debug, Default, Clone)]
1103pub struct Facets {
1104    pub extensions: Vec<String>,
1105    pub lines: i64,
1106}
1107
1108pub fn file_facets(db: &Connection, groups: &[Vec<String>]) -> Result<Facets> {
1109    let (predicate, binds) = glob_predicate(groups);
1110
1111    // The extension is taken in Rust rather than in SQL. SQLite has no
1112    // right-hand search, and the usual `rtrim`/`replace` idiom for it reads a
1113    // dot in a directory name as the start of an extension — which is a wrong
1114    // answer in a column that exists to say what the corpus is made of.
1115    let sql = format!("SELECT f.path FROM files f WHERE {predicate}");
1116    let mut stmt = db.prepare(&sql)?;
1117    let args = binds.clone().into_iter().map(Value::Text);
1118    let paths = stmt.query_map(rusqlite::params_from_iter(args), |r| r.get::<_, String>(0))?;
1119    let mut extensions: Vec<String> = paths
1120        .collect::<Result<Vec<String>, _>>()?
1121        .iter()
1122        .filter_map(|p| {
1123            std::path::Path::new(p)
1124                .extension()
1125                .and_then(|e| e.to_str())
1126                .map(|e| e.to_ascii_lowercase())
1127        })
1128        .collect();
1129    extensions.sort();
1130    extensions.dedup();
1131
1132    let sql = format!(
1133        "SELECT COALESCE(SUM(n), 0) FROM ( \
1134           SELECT COALESCE(MAX(c.end_line), 0) AS n \
1135           FROM files f LEFT JOIN chunks c ON c.file_id = f.id \
1136           WHERE {predicate} GROUP BY f.id)"
1137    );
1138    let mut stmt = db.prepare(&sql)?;
1139    let args = binds.into_iter().map(Value::Text);
1140    let lines: i64 = stmt.query_row(rusqlite::params_from_iter(args), |r| r.get(0))?;
1141
1142    Ok(Facets { extensions, lines })
1143}
1144
1145/// An indexed image: its file's path and the pixel size.
1146#[derive(Debug, Clone)]
1147pub struct ImageRow {
1148    pub id: i64,
1149    pub path: String,
1150    pub width: u32,
1151    pub height: u32,
1152}
1153
1154pub fn insert_image(db: &Connection, file_id: i64, width: u32, height: u32) -> Result<i64> {
1155    db.execute(
1156        "INSERT INTO images (file_id, width, height) VALUES (?1, ?2, ?3)",
1157        params![file_id, width, height],
1158    )?;
1159    Ok(db.last_insert_rowid())
1160}
1161
1162/// One image row, joined with its file's path.
1163pub fn image(db: &Connection, id: i64) -> Result<Option<ImageRow>> {
1164    Ok(db
1165        .query_row(
1166            "SELECT i.id, f.path, i.width, i.height FROM images i \
1167             JOIN files f ON f.id = i.file_id WHERE i.id = ?1",
1168            params![id],
1169            |r| {
1170                Ok(ImageRow {
1171                    id: r.get(0)?,
1172                    path: r.get(1)?,
1173                    width: r.get(2)?,
1174                    height: r.get(3)?,
1175                })
1176            },
1177        )
1178        .optional()?)
1179}
1180
1181/// The image ids belonging to `path`, before its row is deleted.
1182///
1183/// Read rather than returned by `delete_file`, because the cascade that removes
1184/// them is what makes the ids unreadable — and the vectors they address still
1185/// have to be evicted from the image index afterwards.
1186pub fn image_ids_of(db: &Connection, path: &str) -> Result<Vec<i64>> {
1187    let mut stmt =
1188        db.prepare("SELECT i.id FROM images i JOIN files f ON f.id = i.file_id WHERE f.path = ?1")?;
1189    let rows = stmt.query_map(params![path], |r| r.get::<_, i64>(0))?;
1190    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1191}
1192
1193/// How many images this store holds.
1194pub fn image_count(db: &Connection) -> Result<i64> {
1195    Ok(db.query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0))?)
1196}
1197
1198/// Every image id, for the ids a filtered search may consider.
1199pub fn image_ids(db: &Connection) -> Result<Vec<i64>> {
1200    let mut stmt = db.prepare("SELECT id FROM images")?;
1201    let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
1202    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1203}
1204
1205/// The image ids whose file matches `groups`, for a filtered search.
1206pub fn filtered_image_ids(db: &Connection, groups: &[Vec<String>]) -> Result<Vec<i64>> {
1207    let (predicate, binds) = glob_predicate(groups);
1208    let sql =
1209        format!("SELECT i.id FROM images i JOIN files f ON f.id = i.file_id WHERE {predicate}");
1210    let mut stmt = db.prepare(&sql)?;
1211    let args = binds.into_iter().map(Value::Text);
1212    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| r.get::<_, i64>(0))?;
1213    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1214}
1215
1216pub fn all_paths(db: &Connection) -> Result<Vec<String>> {
1217    filtered_paths(db, &[])
1218}
1219
1220/// Everything this store holds for one file, its chunks in order, joined.
1221///
1222/// What `semlith scan` reads. The store's own copy rather than the file on
1223/// disk: the question the scan answers is what this store is holding, and a
1224/// file that has since been cleaned or deleted is still a credential sitting
1225/// in an index an agent can search.
1226pub fn text_of(db: &Connection, path: &str) -> Result<String> {
1227    let mut stmt = db.prepare(
1228        "SELECT c.text FROM chunks c JOIN files f ON f.id = c.file_id
1229         WHERE f.path = ?1 ORDER BY c.ord",
1230    )?;
1231    let rows = stmt.query_map([path], |r| r.get::<_, String>(0))?;
1232    let mut out = String::new();
1233    for row in rows {
1234        out.push_str(&row?);
1235        out.push('\n');
1236    }
1237    Ok(out)
1238}
1239
1240/// Indexed paths whose file matches `groups`, in path order.
1241///
1242/// The same predicate the search halves are built on, so "which files would
1243/// this filter reach" and "which files did this filter search" cannot drift
1244/// apart and give an agent two different answers about one store.
1245pub fn filtered_paths(db: &Connection, groups: &[Vec<String>]) -> Result<Vec<String>> {
1246    let (predicate, binds) = glob_predicate(groups);
1247    let sql = format!("SELECT f.path FROM files f WHERE {predicate} ORDER BY f.path");
1248    let mut stmt = db.prepare(&sql)?;
1249    let args = binds.into_iter().map(Value::Text);
1250    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| r.get::<_, String>(0))?;
1251    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1252}
1253
1254/// Chunks whose file has a committed hash — the ones whose vectors are on disk.
1255///
1256/// A file is recorded with the [`PENDING`](crate::PENDING) empty hash until its
1257/// vectors are durable, so this is exactly what the vector index holds at rest,
1258/// and it costs one indexed count instead of reading the index to find out.
1259pub fn durable_chunks(db: &Connection) -> Result<i64> {
1260    Ok(db.query_row(
1261        "SELECT COUNT(*) FROM chunks c JOIN files f ON f.id = c.file_id WHERE f.hash != ''",
1262        [],
1263        |r| r.get(0),
1264    )?)
1265}
1266
1267/// Apply the schema to a bare in-memory connection, for tests in other
1268/// modules that need somewhere to put symbols and edges.
1269#[cfg(test)]
1270pub fn prepare_for_tests(db: &Connection) {
1271    db.execute_batch(SCHEMA).unwrap();
1272    db.pragma_update(None, "foreign_keys", "ON").unwrap();
1273}
1274
1275/// A symbol row joined with its file's path — what a graph answer resolves to.
1276#[derive(Debug, Clone, serde::Serialize)]
1277pub struct SymbolRow {
1278    pub id: i64,
1279    #[serde(serialize_with = "crate::serialize_plain")]
1280    pub path: String,
1281    pub kind: String,
1282    pub name: String,
1283    pub qualified: String,
1284    pub start_line: u32,
1285    pub end_line: u32,
1286    #[serde(skip_serializing_if = "Option::is_none")]
1287    pub chunk_id: Option<i64>,
1288    /// Which store this came from, when more than one is open. Absent for a
1289    /// single store, so one store's output is what it would have been before
1290    /// stores could be combined.
1291    #[serde(skip_serializing_if = "Option::is_none")]
1292    pub store: Option<String>,
1293}
1294
1295/// One end of a traversal: the symbol reached, and the edge that reached it.
1296#[derive(Debug, Clone, serde::Serialize)]
1297pub struct EdgeEnd {
1298    #[serde(flatten)]
1299    pub symbol: SymbolRow,
1300    pub kind: String,
1301    pub confidence: String,
1302    /// How many definitions of this name the store holds.
1303    ///
1304    /// `1` for an edge that could only ever mean one thing. Greater than one
1305    /// on an `ambiguous` row, where it is the number a renderer prints instead
1306    /// of listing every candidate as though each were a separate call — and on
1307    /// a `resolved` row, where it is how many candidates the ranking had to
1308    /// choose between, which is the difference between "there was only one"
1309    /// and "there were four and the source said which".
1310    pub definitions: usize,
1311    /// The definition this edge actually leaves from.
1312    ///
1313    /// [`edges_out`] is asked about a name, and a name can have several
1314    /// definitions, each with its own edges. Without this the answer says only
1315    /// that *something* called `index` reaches here — which is how a path
1316    /// finder ends up walking out of one definition of a name and into
1317    /// another without anything on the page saying so.
1318    ///
1319    /// `None` from [`edges_in`], where the queried name is the far end of the
1320    /// edge and there is no single row it leaves from.
1321    #[serde(skip_serializing_if = "Option::is_none")]
1322    pub from_path: Option<String>,
1323    #[serde(skip_serializing_if = "Option::is_none")]
1324    pub from_line: Option<u32>,
1325    /// The line the call, import or reference was written on, in the source
1326    /// file named by `from_path`.
1327    ///
1328    /// `None` for an edge written before 0.16.0, and for one whose source file
1329    /// has not been re-indexed since. A renderer says nothing rather than
1330    /// guessing when it is absent.
1331    #[serde(skip_serializing_if = "Option::is_none")]
1332    pub line: Option<u32>,
1333}
1334
1335const SYMBOL_COLUMNS: &str =
1336    "s.id, f.path, s.kind, s.name, s.qualified, s.start_line, s.end_line, s.chunk_id";
1337
1338fn symbol_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<SymbolRow> {
1339    Ok(SymbolRow {
1340        id: r.get(0)?,
1341        path: r.get(1)?,
1342        kind: r.get(2)?,
1343        name: r.get(3)?,
1344        qualified: r.get(4)?,
1345        start_line: r.get(5)?,
1346        end_line: r.get(6)?,
1347        chunk_id: r.get(7)?,
1348        store: None,
1349    })
1350}
1351
1352pub fn insert_symbol(
1353    db: &Connection,
1354    file_id: i64,
1355    chunk_id: Option<i64>,
1356    symbol: &crate::graph::Symbol,
1357) -> Result<i64> {
1358    db.execute(
1359        "INSERT INTO symbols (file_id, chunk_id, kind, name, qualified, start_line, end_line)
1360         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1361        params![
1362            file_id,
1363            chunk_id,
1364            symbol.kind,
1365            symbol.name,
1366            symbol.qualified,
1367            symbol.start_line,
1368            symbol.end_line
1369        ],
1370    )?;
1371    Ok(db.last_insert_rowid())
1372}
1373
1374pub fn insert_edge(
1375    db: &Connection,
1376    src: i64,
1377    dst: &str,
1378    kind: &str,
1379    confidence: &str,
1380    hint: Option<&str>,
1381    line: Option<u32>,
1382) -> Result<()> {
1383    db.execute(
1384        "INSERT INTO edges (src, dst, kind, confidence, hint, line)
1385         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1386        params![src, dst, kind, confidence, hint, line],
1387    )?;
1388    Ok(())
1389}
1390
1391/// Every definition of `name`, across the whole store.
1392///
1393/// Exact match, not a glob: `semlith symbol acquire` asking about `acquire` and
1394/// being handed `acquire_timeout` as well would make the answer something the
1395/// caller has to re-filter.
1396pub fn symbols_named(db: &Connection, name: &str, limit: usize) -> Result<Vec<SymbolRow>> {
1397    let sql = format!(
1398        "SELECT {SYMBOL_COLUMNS} FROM symbols s JOIN files f ON f.id = s.file_id
1399         WHERE s.name = ?1 ORDER BY f.path, s.start_line LIMIT ?2"
1400    );
1401    let mut stmt = db.prepare(&sql)?;
1402    let rows = stmt.query_map(params![name, limit as i64], symbol_row)?;
1403    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1404}
1405
1406/// Symbols to draw for a scope, newest-defined first within each file.
1407///
1408/// `prefix` narrows to a directory or a file the way the Files view does.
1409/// Module symbols are left out: every file has one, so they would fill the
1410/// budget with nodes that say only "this file exists".
1411pub fn symbols_scoped(
1412    db: &Connection,
1413    prefix: Option<&str>,
1414    limit: usize,
1415) -> Result<Vec<SymbolRow>> {
1416    let (predicate, binds) = match prefix {
1417        Some(p) => {
1418            let pattern = format!("*{}*", p.to_lowercase());
1419            ("lower(f.path) GLOB ?".to_string(), vec![pattern])
1420        }
1421        None => ("1".to_string(), Vec::new()),
1422    };
1423    // Busiest first. A scope filled with symbols that touch nothing draws a
1424    // field of dots: technically the graph, and of no use to anyone looking at
1425    // it. Ordering by how connected a symbol is puts the shape on the screen.
1426    let sql = format!(
1427        "SELECT {SYMBOL_COLUMNS} FROM symbols s JOIN files f ON f.id = s.file_id
1428         WHERE {predicate} AND s.kind != 'module'
1429         ORDER BY (
1430           (SELECT COUNT(*) FROM edges e WHERE e.src = s.id)
1431           + (SELECT COUNT(*) FROM edges e WHERE e.dst = s.name)
1432         ) DESC, f.path, s.start_line LIMIT ?"
1433    );
1434    let mut stmt = db.prepare(&sql)?;
1435    let mut args: Vec<Value> = binds.into_iter().map(Value::Text).collect();
1436    args.push(Value::Integer(limit as i64));
1437    let rows = stmt.query_map(rusqlite::params_from_iter(args), symbol_row)?;
1438    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1439}
1440
1441/// Symbols defined in files matching `groups` whose name is one of `names`.
1442///
1443/// The resolution step every traversal shares: an edge names its target, and
1444/// this is what turns that name back into rows. The filter is threaded through
1445/// so graph expansion obeys the same chunk-id set as the other two search
1446/// halves rather than reaching outside it.
1447pub fn symbols_by_names(
1448    db: &Connection,
1449    names: &[String],
1450    groups: &[Vec<String>],
1451) -> Result<Vec<SymbolRow>> {
1452    if names.is_empty() {
1453        return Ok(Vec::new());
1454    }
1455    let (predicate, mut binds) = glob_predicate(groups);
1456    let holes = vec!["?"; names.len()].join(", ");
1457    let sql = format!(
1458        "SELECT {SYMBOL_COLUMNS} FROM symbols s JOIN files f ON f.id = s.file_id
1459         WHERE {predicate} AND s.name IN ({holes}) ORDER BY f.path, s.start_line"
1460    );
1461    binds.extend(names.iter().cloned());
1462    let mut stmt = db.prepare(&sql)?;
1463    let args = binds.into_iter().map(Value::Text);
1464    let rows = stmt.query_map(rusqlite::params_from_iter(args), symbol_row)?;
1465    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1466}
1467
1468/// What the symbols named `name` point at: callees, imports, references out.
1469///
1470/// One hop. `kinds` empty means every edge kind.
1471///
1472/// # Resolution
1473///
1474/// `edges.dst` is a name, and a name is not an address: a store of any size
1475/// holds four `get`s and seven `index`es. Until 0.15.0 this returned every
1476/// definition that shared the name, each as its own row and each looking
1477/// exactly like a call the source makes — which is how `semlith path` came to
1478/// answer "yes, connected" to questions whose honest answer is "no".
1479///
1480/// Each edge is now resolved against its candidates by [`rank`], and the
1481/// confidence it comes back with says how it was settled:
1482///
1483/// - `extracted` — the file named where the target came from. Stored, and
1484///   never overwritten here: a fact from the syntax tree outranks a ranking.
1485/// - `resolved` — the candidates narrowed to exactly one. Either the source
1486///   narrowed them (same file, the hint, an import) or the corpus did, by
1487///   holding only one definition of the name.
1488/// - `ambiguous` — several candidates survived and nothing chose between them.
1489///   Every candidate is returned, each marked `ambiguous` and each carrying
1490///   the count, so a renderer can collapse them to one row and a traversal can
1491///   refuse to cross them.
1492///
1493/// `inferred` is the stored value and is what [`edges_in`] and the edge census
1494/// still show. It does not come back from here, because by the time a target
1495/// has been looked up there is always something to say about it: either one
1496/// definition answers to the name or several do.
1497///
1498/// A resolution is against the indexed corpus, not against the world. One
1499/// definition of `parse` in the store does not prove the call went to it
1500/// rather than to a dependency that was never indexed — that is what the
1501/// hidden-by-default unresolved targets are about, not this.
1502pub fn edges_out(db: &Connection, name: &str, kinds: &[String]) -> Result<Vec<EdgeEnd>> {
1503    let filter = kind_predicate(kinds, "e.kind");
1504    // An edge's target is resolved by name, so without this a configuration key
1505    // named `path` is a candidate definition of every `path` any code calls.
1506    let target = crate::graph::not_navigational("s.kind");
1507    let sql = format!(
1508        "SELECT {SYMBOL_COLUMNS}, e.kind, e.confidence, e.hint, srcf.path, src.start_line, e.line
1509         FROM symbols src
1510         JOIN files srcf ON srcf.id = src.file_id
1511         JOIN edges e ON e.src = src.id
1512         JOIN symbols s ON s.name = e.dst AND {target}
1513         JOIN files f ON f.id = s.file_id
1514         WHERE src.name = ?1 AND {filter}
1515         ORDER BY f.path, s.start_line"
1516    );
1517    let mut stmt = db.prepare(&sql)?;
1518    let mut binds: Vec<Value> = vec![Value::Text(name.to_string())];
1519    binds.extend(kinds.iter().map(|k| Value::Text(k.clone())));
1520    let rows = stmt.query_map(rusqlite::params_from_iter(binds), |r| {
1521        Ok(Reached {
1522            symbol: symbol_row(r)?,
1523            kind: r.get(8)?,
1524            confidence: r.get(9)?,
1525            hint: r.get(10)?,
1526            src_path: r.get(11)?,
1527            src_line: r.get(12)?,
1528            line: r.get(13)?,
1529        })
1530    })?;
1531    let reached = rows.collect::<Result<Vec<_>, _>>()?;
1532    resolve(db, reached)
1533}
1534
1535/// One row of the join behind [`edges_out`]: a candidate for an edge's target,
1536/// with everything the ranking needs about the edge itself.
1537struct Reached {
1538    symbol: SymbolRow,
1539    kind: String,
1540    confidence: String,
1541    hint: Option<String>,
1542    src_path: String,
1543    src_line: u32,
1544    line: Option<u32>,
1545}
1546
1547/// Turn candidate rows into resolved edges.
1548///
1549/// The rows arrive as the cross product of edges and the definitions their
1550/// targets could mean, so they are grouped back into edges first. Order is
1551/// preserved: the first time an edge is seen decides where its rows sit in the
1552/// answer, so a caller that used to read this list top to bottom still reads
1553/// it in the same order.
1554fn resolve(db: &Connection, reached: Vec<Reached>) -> Result<Vec<EdgeEnd>> {
1555    // (source file, target name, edge kind, hint) — one edge in the source.
1556    type Key = (String, String, String, Option<String>);
1557    let mut order: Vec<Key> = Vec::new();
1558    let mut groups: std::collections::HashMap<Key, Vec<Reached>> = std::collections::HashMap::new();
1559    for row in reached {
1560        let key = (
1561            row.src_path.clone(),
1562            row.symbol.name.clone(),
1563            row.kind.clone(),
1564            row.hint.clone(),
1565        );
1566        if !groups.contains_key(&key) {
1567            order.push(key.clone());
1568        }
1569        groups.entry(key).or_default().push(row);
1570    }
1571
1572    // The imports of each source file, read once and only when a group
1573    // actually needs them: the first two tiers settle most edges, and a file's
1574    // imports are a query this should not pay for on every hop of a traversal.
1575    let mut imports: std::collections::HashMap<String, Vec<String>> =
1576        std::collections::HashMap::new();
1577
1578    let mut out = Vec::new();
1579    for key in order {
1580        let candidates = groups.remove(&key).unwrap_or_default();
1581        let (src_path, _, _, hint) = &key;
1582        let definitions = candidates.len();
1583
1584        if definitions == 1 {
1585            // Nothing to choose between. Whatever the syntax tree said stands,
1586            // and a bare name that matches exactly one definition in the store
1587            // is as resolved as the corpus can make it.
1588            let row = candidates.into_iter().next().expect("one candidate");
1589            let confidence = if row.confidence == crate::graph::EXTRACTED {
1590                crate::graph::EXTRACTED
1591            } else {
1592                crate::graph::RESOLVED
1593            };
1594            out.push(EdgeEnd {
1595                symbol: row.symbol,
1596                kind: row.kind,
1597                confidence: confidence.to_string(),
1598                definitions,
1599                from_path: Some(row.src_path),
1600                from_line: Some(row.src_line),
1601                line: row.line,
1602            });
1603            continue;
1604        }
1605
1606        // Rank without the imports first. If that already leaves one winner,
1607        // the import query is never run.
1608        let mut ranks: Vec<u8> = candidates
1609            .iter()
1610            .map(|c| rank(&c.symbol.path, src_path, hint.as_deref(), &[]))
1611            .collect();
1612        if survivors(&ranks) != 1 && ranks.contains(&UNRANKED) {
1613            let of_file = match imports.get(src_path) {
1614                Some(found) => found.clone(),
1615                None => {
1616                    let found = file_imports(db, src_path)?;
1617                    imports.insert(src_path.clone(), found.clone());
1618                    found
1619                }
1620            };
1621            ranks = candidates
1622                .iter()
1623                .map(|c| rank(&c.symbol.path, src_path, hint.as_deref(), &of_file))
1624                .collect();
1625        }
1626
1627        let best = ranks.iter().copied().min().unwrap_or(UNRANKED);
1628        let settled = survivors(&ranks) == 1;
1629        for (row, rank) in candidates.into_iter().zip(&ranks) {
1630            if settled && *rank != best {
1631                continue;
1632            }
1633            let confidence = if row.confidence == crate::graph::EXTRACTED && settled {
1634                crate::graph::EXTRACTED
1635            } else if settled {
1636                crate::graph::RESOLVED
1637            } else {
1638                crate::graph::AMBIGUOUS
1639            };
1640            out.push(EdgeEnd {
1641                symbol: row.symbol,
1642                kind: row.kind,
1643                confidence: confidence.to_string(),
1644                definitions,
1645                from_path: Some(row.src_path),
1646                from_line: Some(row.src_line),
1647                line: row.line,
1648            });
1649        }
1650    }
1651    Ok(out)
1652}
1653
1654/// The rank of a candidate that nothing placed. Sorts last, and is what says
1655/// "the imports are worth reading for this group".
1656const UNRANKED: u8 = 3;
1657
1658/// How many candidates sit at the best rank.
1659fn survivors(ranks: &[u8]) -> usize {
1660    match ranks.iter().min() {
1661        Some(best) => ranks.iter().filter(|r| *r == best).count(),
1662        None => 0,
1663    }
1664}
1665
1666/// Where a candidate definition sits in the ranking, lower being better.
1667///
1668/// 0. The same file as the call. A file that defines a name and calls it means
1669///    its own.
1670/// 1. A file the hint names. `store::edges_out` in the presence of
1671///    `src/store.rs` is not a coincidence.
1672/// 2. A file the calling file imports. Weaker than the hint because an import
1673///    list is a set of possibilities rather than a statement about this call.
1674/// 3. Nothing placed it.
1675fn rank(candidate: &str, src_path: &str, hint: Option<&str>, imports: &[String]) -> u8 {
1676    if candidate == src_path {
1677        return 0;
1678    }
1679    if hint.is_some_and(|h| names_file(h, candidate)) {
1680        return 1;
1681    }
1682    if imports.iter().any(|i| {
1683        i.split(['/', '.', ':', '\\'])
1684            .any(|segment| !segment.is_empty() && names_file(segment, candidate))
1685    }) {
1686        return 2;
1687    }
1688    // A same-language tier was tried here and removed
1689    // (US-SEMLITH-0.17.0-I01). The reasoning was sound — semlith's own portal
1690    // is five thousand lines of JavaScript sharing twenty-five symbol names
1691    // with the Rust beside it, and a Rust `calls` edge does not mean a
1692    // JavaScript function — but the harness scored it two hits worse: it
1693    // settles an ambiguous name in favour of a same-language candidate that is
1694    // not always the right one, and a confidently wrong edge costs more than a
1695    // refused one. Ambiguity across languages is a ranking problem, and it is
1696    // not solved by preferring the nearest guess.
1697    UNRANKED
1698}
1699
1700/// Whether `word` names the file at `path` — its stem, or one of its
1701/// directories.
1702///
1703/// `store` names `src/store.rs` and `src/store/mod.rs` alike, which is the
1704/// point: a module is a file or a directory depending on how the author felt
1705/// that day, and a hint knows neither.
1706fn names_file(word: &str, path: &str) -> bool {
1707    let path = std::path::Path::new(path);
1708    if path.file_stem().is_some_and(|stem| stem == word) {
1709        return true;
1710    }
1711    path.parent()
1712        .into_iter()
1713        .flat_map(|parent| parent.components())
1714        .any(|component| component.as_os_str() == word)
1715}
1716
1717/// Everything one file imports, as the raw strings the extractor recorded.
1718fn file_imports(db: &Connection, path: &str) -> Result<Vec<String>> {
1719    let mut stmt = db.prepare(
1720        "SELECT DISTINCT e.dst FROM edges e
1721         JOIN symbols s ON s.id = e.src
1722         JOIN files f ON f.id = s.file_id
1723         WHERE f.path = ?1 AND e.kind = 'imports'",
1724    )?;
1725    let rows = stmt.query_map(params![path], |r| r.get(0))?;
1726    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1727}
1728
1729/// Targets of `name`'s edges that the store holds no definition for.
1730///
1731/// [`edges_out`] joins to `symbols`, so an edge pointing at something outside
1732/// the corpus — a standard-library call, a crate that was never indexed —
1733/// simply does not appear there. That is the right default: a list of names
1734/// the store knows nothing about is noise in an answer about this codebase.
1735/// It is not the right *only* option, because "semlith shows no callees" and
1736/// "everything this calls lives outside the index" are different facts, and
1737/// only one of them means the graph is working.
1738pub fn unresolved_out(db: &Connection, name: &str, kinds: &[String]) -> Result<Vec<Unresolved>> {
1739    let filter = kind_predicate(kinds, "e.kind");
1740    let sql = format!(
1741        "SELECT DISTINCT e.dst, e.kind, e.confidence
1742         FROM symbols src
1743         JOIN edges e ON e.src = src.id
1744         WHERE src.name = ?1 AND {filter}
1745           AND NOT EXISTS (SELECT 1 FROM symbols s WHERE s.name = e.dst)
1746         ORDER BY e.dst"
1747    );
1748    let mut stmt = db.prepare(&sql)?;
1749    let mut binds: Vec<Value> = vec![Value::Text(name.to_string())];
1750    binds.extend(kinds.iter().map(|k| Value::Text(k.clone())));
1751    let rows = stmt.query_map(rusqlite::params_from_iter(binds), |r| {
1752        Ok(Unresolved {
1753            name: r.get(0)?,
1754            kind: r.get(1)?,
1755            confidence: r.get(2)?,
1756        })
1757    })?;
1758    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1759}
1760
1761/// An edge whose target this store has no definition for.
1762#[derive(Debug, Clone, serde::Serialize)]
1763pub struct Unresolved {
1764    pub name: String,
1765    pub kind: String,
1766    pub confidence: String,
1767}
1768
1769/// What points at `name`: callers, importers, references in.
1770///
1771/// The direction a reverse walk takes, and the reason `edges_dst` exists —
1772/// `semlith neighbors` reads it to answer "what calls this".
1773pub fn edges_in(db: &Connection, name: &str, kinds: &[String]) -> Result<Vec<EdgeEnd>> {
1774    let filter = kind_predicate(kinds, "e.kind");
1775    let sql = format!(
1776        "SELECT {SYMBOL_COLUMNS}, e.kind, e.confidence, e.line
1777         FROM edges e
1778         JOIN symbols s ON s.id = e.src
1779         JOIN files f ON f.id = s.file_id
1780         WHERE e.dst = ?1 AND {filter}
1781         ORDER BY f.path, s.start_line"
1782    );
1783    let mut stmt = db.prepare(&sql)?;
1784    let mut binds: Vec<Value> = vec![Value::Text(name.to_string())];
1785    binds.extend(kinds.iter().map(|k| Value::Text(k.clone())));
1786    let rows = stmt.query_map(rusqlite::params_from_iter(binds), |r| {
1787        Ok(EdgeEnd {
1788            symbol: symbol_row(r)?,
1789            kind: r.get(8)?,
1790            confidence: r.get(9)?,
1791            // A caller is a symbol, not a name: the row came from the edge's
1792            // own `src` id, so there is nothing to resolve and nothing to be
1793            // ambiguous about. This direction is unchanged from 0.14.0.
1794            definitions: 1,
1795            from_path: None,
1796            from_line: None,
1797            line: r.get(10)?,
1798        })
1799    })?;
1800    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1801}
1802
1803fn kind_predicate(kinds: &[String], column: &str) -> String {
1804    if kinds.is_empty() {
1805        return "1".to_string();
1806    }
1807    let holes = vec!["?"; kinds.len()].join(", ");
1808    format!("{column} IN ({holes})")
1809}
1810
1811/// The names of every symbol whose defining lines overlap one of `chunk_ids`.
1812///
1813/// The seed of graph expansion: search returns chunks, the graph knows symbols,
1814/// and this is the join between them.
1815pub fn symbols_in_chunks(db: &Connection, chunk_ids: &[u64]) -> Result<Vec<String>> {
1816    if chunk_ids.is_empty() {
1817        return Ok(Vec::new());
1818    }
1819    let holes = vec!["?"; chunk_ids.len()].join(", ");
1820    // The one caller is the seed of the ranked walk, and the walk runs over
1821    // dependency edges. A heading or a configuration key has none, so seeding
1822    // it spends the walk's node budget to reach nothing — and where its name
1823    // collides with a real symbol's, it spends the seed's mass on the wrong
1824    // one.
1825    let navigational = crate::graph::not_navigational("kind");
1826    let sql =
1827        format!("SELECT DISTINCT name FROM symbols WHERE chunk_id IN ({holes}) AND {navigational}");
1828    let mut stmt = db.prepare(&sql)?;
1829    let args = chunk_ids.iter().map(|i| Value::Integer(*i as i64));
1830    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| r.get::<_, String>(0))?;
1831    Ok(rows.collect::<Result<Vec<_>, _>>()?)
1832}
1833
1834/// One recorded retrieval.
1835#[derive(Debug, Clone, serde::Serialize)]
1836pub struct Retrieval {
1837    pub id: i64,
1838    pub at: i64,
1839    pub client: String,
1840    pub query: String,
1841    pub hits: i64,
1842    pub micros: i64,
1843    pub excerpt_tokens: i64,
1844    pub whole_file_tokens: i64,
1845    pub hash: String,
1846    /// Which stores answered this one retrieval, so several rows can be shown
1847    /// as the one search they came from. Empty for a row written before
1848    /// 0.20.2, which was its own retrieval.
1849    pub query_id: String,
1850}
1851
1852/// Append one retrieval, chained to the row before it.
1853///
1854/// The chain is the point: a row cannot be quietly edited or removed without
1855/// every hash after it failing to recompute.
1856pub fn record_retrieval(db: &Connection, row: &NewRetrieval<'_>) -> Result<()> {
1857    let (client, query) = (row.client, row.query);
1858    let (hits, micros) = (row.hits, row.micros);
1859    let (excerpt_tokens, whole_file_tokens) = (row.excerpt_tokens, row.whole_file_tokens);
1860    // The ledger is the one write that happens on a read: a retrieval is
1861    // recorded by the search that answered it. It asks for the same permission
1862    // an index run does.
1863    let _writing = Writing::begin(db)?;
1864    let at = std::time::SystemTime::now()
1865        .duration_since(std::time::UNIX_EPOCH)
1866        .map(|d| d.as_secs() as i64)
1867        .unwrap_or(0);
1868    let prev: String = db
1869        .query_row(
1870            "SELECT hash FROM retrievals ORDER BY id DESC LIMIT 1",
1871            [],
1872            |r| r.get(0),
1873        )
1874        .optional()?
1875        .unwrap_or_default();
1876    let hash = chain_hash(&prev, at, row);
1877    db.execute(
1878        "INSERT INTO retrievals
1879         (at, client, query, hits, micros, excerpt_tokens, whole_file_tokens, prev, hash,
1880          session, tool, stale_hits, tokenizer, query_id)
1881         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
1882        params![
1883            at,
1884            client,
1885            query,
1886            hits,
1887            micros,
1888            excerpt_tokens,
1889            whole_file_tokens,
1890            prev,
1891            hash,
1892            row.session,
1893            row.tool,
1894            row.stale_hits,
1895            row.tokenizer,
1896            row.query_id
1897        ],
1898    )?;
1899    // The one place a retrieval is written, so the one place the portal's
1900    // ledger counter moves — every surface that records goes through here.
1901    crate::daemon::changes::bump(crate::daemon::changes::Domain::Ledger);
1902    Ok(())
1903}
1904
1905/// One retrieval, before it has a timestamp or a place in the chain.
1906///
1907/// A struct rather than thirteen positional arguments, because two adjacent
1908/// `i64`s that mean different things are a bug waiting for a refactor.
1909#[derive(Debug, Clone)]
1910pub struct NewRetrieval<'a> {
1911    /// Who asked: `claude-code`, `cursor`, `cli`, `portal`.
1912    pub client: &'a str,
1913    /// Which conversation, so one agent's session can be read as a unit.
1914    pub session: &'a str,
1915    /// Which tool: `search`, `neighbors`, `path`, `symbol`.
1916    pub tool: &'a str,
1917    pub query: &'a str,
1918    pub hits: i64,
1919    pub micros: i64,
1920    pub excerpt_tokens: i64,
1921    pub whole_file_tokens: i64,
1922    /// How many of those hits came from a file edited since it was indexed.
1923    pub stale_hits: i64,
1924    /// What counted the two token figures. Rows counted two different ways are
1925    /// never summed together, so the label travels with the row rather than
1926    /// being assumed from its age.
1927    pub tokenizer: &'a str,
1928    /// The id shared by every row one retrieval wrote.
1929    ///
1930    /// A cross-store search writes a row in each store that answered it, so
1931    /// each store's chain stays its own and its token figures describe its own
1932    /// hits. This is what says those rows are one retrieval rather than
1933    /// several, and it is what "queries recorded" counts.
1934    pub query_id: &'a str,
1935}
1936
1937/// The hash covering one row and the one before it.
1938///
1939/// # Two formulas
1940///
1941/// A row written before 0.15.0 has four columns this one does not, and its
1942/// hash was computed without them. Recomputing such a row under the new
1943/// formula would report every 0.14.0 ledger as broken — which is the one thing
1944/// a verify must never do, because a verify that cries wolf is worse than no
1945/// verify at all.
1946///
1947/// So the formula is chosen per row, by the row itself: `tool` is NULL on
1948/// every row written before 0.15.0 and set on every row written since. There
1949/// is no version column and no migration, and a store holding rows of both
1950/// kinds walks end to end.
1951fn chain_hash(prev: &str, at: i64, row: &NewRetrieval<'_>) -> String {
1952    let NewRetrieval {
1953        client,
1954        query,
1955        hits,
1956        micros,
1957        excerpt_tokens: excerpt,
1958        whole_file_tokens: whole,
1959        ..
1960    } = row;
1961    let mut payload = format!(
1962        "{prev}\u{1f}{at}\u{1f}{client}\u{1f}{query}\u{1f}{hits}\u{1f}{micros}\u{1f}{excerpt}\u{1f}{whole}"
1963    );
1964    payload.push_str(&format!(
1965        "\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
1966        row.session, row.tool, row.stale_hits, row.tokenizer
1967    ));
1968    // `query_id` is deliberately NOT in the payload.
1969    //
1970    // Adding it would be a third formula, and a 0.20.1 binary — which is this
1971    // release's own rollback path — recomputes the second one. Every row
1972    // written since would then fail to verify under the version somebody
1973    // rolled back to, which is the one thing a verify must never do: a verify
1974    // that cries wolf is worse than no verify at all.
1975    //
1976    // What that costs is stated rather than hidden: the id can be altered
1977    // without breaking the chain, so the grouping of rows into retrievals is
1978    // not tamper-evident. What a retrieval *was* — who asked, what they asked,
1979    // how many hits, what it cost — is covered exactly as before.
1980    blake3::hash(payload.as_bytes()).to_hex().to_string()
1981}
1982
1983/// Append a row in the shape 0.14.0 wrote, for the test that proves a store
1984/// holding both kinds still verifies.
1985///
1986/// Public because that test is an integration test and cannot reach a private
1987/// function — and because the property it proves is worth proving. A verify
1988/// that reported every ledger written before this release as broken would be
1989/// worse than no verify at all, and nothing but a real pre-0.15.0 row in a
1990/// real store demonstrates that it does not.
1991pub fn record_legacy_retrieval(
1992    db: &Connection,
1993    client: &str,
1994    query: &str,
1995    excerpt_tokens: i64,
1996    whole_file_tokens: i64,
1997) -> Result<()> {
1998    let _writing = Writing::begin(db)?;
1999    let at = std::time::SystemTime::now()
2000        .duration_since(std::time::UNIX_EPOCH)
2001        .map(|d| d.as_secs() as i64)
2002        .unwrap_or(0);
2003    let prev: String = db
2004        .query_row(
2005            "SELECT hash FROM retrievals ORDER BY id DESC LIMIT 1",
2006            [],
2007            |r| r.get(0),
2008        )
2009        .optional()?
2010        .unwrap_or_default();
2011    let (hits, micros) = (1, 1000);
2012    let hash = legacy_chain_hash(
2013        &prev,
2014        at,
2015        client,
2016        query,
2017        hits,
2018        micros,
2019        excerpt_tokens,
2020        whole_file_tokens,
2021    );
2022    // The four 0.15.0 columns are left NULL, which is what makes this a row of
2023    // the older kind and what `ledger_break` reads to choose the formula.
2024    db.execute(
2025        "INSERT INTO retrievals
2026         (at, client, query, hits, micros, excerpt_tokens, whole_file_tokens, prev, hash)
2027         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
2028        params![
2029            at,
2030            client,
2031            query,
2032            hits,
2033            micros,
2034            excerpt_tokens,
2035            whole_file_tokens,
2036            prev,
2037            hash
2038        ],
2039    )?;
2040    Ok(())
2041}
2042
2043/// The 0.14.0 formula, for rows written under it.
2044#[allow(clippy::too_many_arguments)]
2045fn legacy_chain_hash(
2046    prev: &str,
2047    at: i64,
2048    client: &str,
2049    query: &str,
2050    hits: i64,
2051    micros: i64,
2052    excerpt: i64,
2053    whole: i64,
2054) -> String {
2055    let payload = format!(
2056        "{prev}\u{1f}{at}\u{1f}{client}\u{1f}{query}\u{1f}{hits}\u{1f}{micros}\u{1f}{excerpt}\u{1f}{whole}"
2057    );
2058    blake3::hash(payload.as_bytes()).to_hex().to_string()
2059}
2060
2061/// The most recent `limit` retrievals, newest first.
2062pub fn retrievals(db: &Connection, limit: usize) -> Result<Vec<Retrieval>> {
2063    let mut stmt = db.prepare(
2064        "SELECT id, at, client, query, hits, micros, excerpt_tokens, whole_file_tokens, hash,
2065                query_id
2066         FROM retrievals ORDER BY id DESC LIMIT ?1",
2067    )?;
2068    let rows = stmt.query_map(params![limit as i64], |r| {
2069        Ok(Retrieval {
2070            id: r.get(0)?,
2071            at: r.get(1)?,
2072            client: r.get(2)?,
2073            query: r.get(3)?,
2074            hits: r.get(4)?,
2075            micros: r.get(5)?,
2076            excerpt_tokens: r.get(6)?,
2077            whole_file_tokens: r.get(7)?,
2078            hash: r.get(8)?,
2079            query_id: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
2080        })
2081    })?;
2082    Ok(rows.collect::<Result<Vec<_>, _>>()?)
2083}
2084
2085/// Which rows of the ledger a count is about.
2086#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2087pub enum LedgerScope {
2088    /// Every retrieval.
2089    All,
2090    /// Those that found something, which are the only ones credited with a
2091    /// saving.
2092    Credited,
2093    /// Those credited whose tokens were estimated rather than counted by the
2094    /// store's own tokenizer.
2095    Estimated,
2096}
2097
2098/// The distinct retrievals this store holds, as keys that can be unioned
2099/// across stores.
2100///
2101/// One search over six stores writes a row in each that answered it, sharing a
2102/// query id. Summing six stores' own counts turns that back into six
2103/// retrievals, which is the multiplier the Ledger page was reporting — so the
2104/// portal unions these keys instead of adding counts. A row written before
2105/// 0.20.2 has no id and is keyed by its own row id in its own store, which is
2106/// what it was.
2107pub fn ledger_keys(db: &Connection, store: &str, scope: LedgerScope) -> Result<Vec<String>> {
2108    let sql = match scope {
2109        LedgerScope::All => "SELECT DISTINCT query_id, id FROM retrievals",
2110        LedgerScope::Credited => "SELECT DISTINCT query_id, id FROM retrievals WHERE hits > 0",
2111        LedgerScope::Estimated => {
2112            "SELECT DISTINCT query_id, id FROM retrievals
2113             WHERE hits > 0 AND (tokenizer IS NULL OR tokenizer != 'model')"
2114        }
2115    };
2116    let mut stmt = db.prepare(sql)?;
2117    let rows = stmt.query_map([], |r| {
2118        let id: Option<String> = r.get(0)?;
2119        let row: i64 = r.get(1)?;
2120        Ok(id.unwrap_or_else(|| format!("{store}:row:{row}")))
2121    })?;
2122    let mut out: Vec<String> = rows.collect::<Result<Vec<_>, _>>()?;
2123    out.sort_unstable();
2124    out.dedup();
2125    Ok(out)
2126}
2127
2128/// `(queries, clients, excerpt tokens, whole-file tokens)` over the whole/// `(queries, clients, excerpt tokens, whole-file tokens)` over the whole
2129/// ledger — what the Ledger page shows.
2130pub fn ledger_totals(db: &Connection) -> Result<(i64, i64, i64, i64)> {
2131    // Retrievals, not rows. One search across six stores writes a row in each
2132    // store that answered it and they share one `query_id`, so counting rows
2133    // here is what made every figure on the Ledger page scale with how many
2134    // stores happened to be open. A row written before 0.20.2 has no id and is
2135    // its own retrieval, which is what it was.
2136    Ok(db.query_row(
2137        "SELECT COUNT(DISTINCT COALESCE(query_id, 'row:' || id)), COUNT(DISTINCT client),
2138                COALESCE(SUM(excerpt_tokens), 0),
2139                COALESCE(SUM(whole_file_tokens), 0) FROM retrievals",
2140        [],
2141        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
2142    )?)
2143}
2144
2145/// The one savings figure, with the denominators that make it readable.
2146///
2147/// `net` is what the ledger says was not read: the whole-file cost of the
2148/// files an answer named, less what the answer itself cost. Rows that found
2149/// nothing are excluded from it and counted separately — a retrieval that
2150/// returned no hits saved nothing, and a ledger that quietly dropped those
2151/// rows would report a ratio that no honest denominator supports.
2152///
2153/// `tier` is `measured` when every credited row was counted with the store's
2154/// own tokenizer, and `modelled` when any of them was estimated at four
2155/// characters per token. The two are never summed: a mixed ledger reports
2156/// `modelled`, because that is what the weaker half makes the whole.
2157///
2158/// # What this figure does not do
2159///
2160/// It does not deduplicate files across the retrievals of one session. If an
2161/// agent searches twice and both answers name `src/lib.rs`, the denominator
2162/// counts that file twice, and the real alternative — one read — is cheaper
2163/// than the figure implies. Fixing it needs the file set stored per row, which
2164/// this release does not add. The number is therefore an upper bound on what
2165/// was saved, and `semlith stats` says so rather than presenting it as exact.
2166pub fn ledger_savings(db: &Connection) -> Result<Savings> {
2167    ledger_savings_since(db, 0)
2168}
2169
2170/// The savings, over retrievals recorded at or after `since`.
2171///
2172/// `since` of 0 is every row, which is what [`ledger_savings`] asks for and
2173/// what every caller before 0.27.0 meant. The ledger has always carried `at`
2174/// and an index on it; what it did not have was a reader that took a bound, so
2175/// the Reports page drew a window control over a figure that could not honour
2176/// one. A control that cannot change the thing it claims to change is worse
2177/// than no control.
2178pub fn ledger_savings_since(db: &Connection, since: i64) -> Result<Savings> {
2179    // Retrievals rather than rows on both sides of the ratio, for the reason
2180    // `ledger_totals` gives: the rows of one cross-store search are one search.
2181    // The token sums stay sums of rows, because each row holds its own store's
2182    // hits and nothing is double-counted by adding them.
2183    let (credited, net): (i64, i64) = db.query_row(
2184        "SELECT COUNT(DISTINCT COALESCE(query_id, 'row:' || id)),
2185                COALESCE(SUM(whole_file_tokens - excerpt_tokens), 0)
2186         FROM retrievals WHERE hits > 0 AND at >= ?1",
2187        [since],
2188        |r| Ok((r.get(0)?, r.get(1)?)),
2189    )?;
2190    let total: i64 = db.query_row(
2191        "SELECT COUNT(DISTINCT COALESCE(query_id, 'row:' || id)) FROM retrievals
2192         WHERE at >= ?1",
2193        [since],
2194        |r| r.get(0),
2195    )?;
2196    let estimated: i64 = db.query_row(
2197        "SELECT COUNT(DISTINCT COALESCE(query_id, 'row:' || id)) FROM retrievals
2198         WHERE hits > 0 AND at >= ?1 AND (tokenizer IS NULL OR tokenizer != 'model')",
2199        [since],
2200        |r| r.get(0),
2201    )?;
2202    Ok(Savings {
2203        net: net.max(0),
2204        credited,
2205        total,
2206        measured: credited > 0 && estimated == 0,
2207    })
2208}
2209
2210/// What the ledger adds up to.
2211#[derive(Debug, Clone, Copy, serde::Serialize)]
2212pub struct Savings {
2213    /// Whole-file tokens less excerpt tokens, over rows that found something.
2214    pub net: i64,
2215    /// How many retrievals that was over.
2216    pub credited: i64,
2217    /// How many retrievals there are in total, credited or not. The
2218    /// denominator, so a figure is never shown without one.
2219    pub total: i64,
2220    /// Whether every credited row was counted with the store's own tokenizer.
2221    pub measured: bool,
2222}
2223
2224impl Savings {
2225    /// `measured` or `modelled`.
2226    pub fn tier(&self) -> &'static str {
2227        if self.measured {
2228            "measured"
2229        } else {
2230            "modelled"
2231        }
2232    }
2233
2234    /// What share of retrievals the figure covers, as a percentage.
2235    pub fn coverage(&self) -> i64 {
2236        if self.total == 0 {
2237            return 0;
2238        }
2239        self.credited * 100 / self.total
2240    }
2241}
2242
2243/// What the ledger says about the questions semlith did *not* answer.
2244///
2245/// `refunds` counts the whole-file reads the steering hook saw on a file this
2246/// store holds: semlith could have answered them and was not asked. `zero_hit`
2247/// counts the retrievals semlith *was* asked and found nothing for. They are
2248/// separate figures because they call for opposite things — a refund means the
2249/// agent is not reaching for semlith, a zero hit means semlith is not reaching
2250/// the answer — and adding them together would say neither.
2251///
2252/// `measured` is false when no hook has ever written into this store, which is
2253/// what makes `refunds` a floor rather than a count on such a machine.
2254pub fn ledger_misses(db: &Connection) -> Result<Misses> {
2255    let refunds: i64 = db.query_row(
2256        "SELECT COUNT(*) FROM retrievals WHERE tool = ?1",
2257        params![crate::ledger::RAW_READ],
2258        |r| r.get(0),
2259    )?;
2260    let zero_hit: i64 = db.query_row(
2261        "SELECT COUNT(DISTINCT COALESCE(query_id, 'row:' || id)) FROM retrievals
2262         WHERE hits = 0 AND (tool IS NULL OR tool != ?1)",
2263        params![crate::ledger::RAW_READ],
2264        |r| r.get(0),
2265    )?;
2266    Ok(Misses {
2267        refunds,
2268        zero_hit,
2269        measured: refunds > 0,
2270    })
2271}
2272
2273/// The two figures for what semlith did not answer.
2274#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
2275pub struct Misses {
2276    /// Whole-file reads of a file this store holds, seen by the steering hook.
2277    pub refunds: i64,
2278    /// Retrievals semlith answered with nothing.
2279    pub zero_hit: i64,
2280    /// Whether a hook has ever written here. Without one, `refunds` is a floor.
2281    pub measured: bool,
2282}
2283
2284/// How many retrievals each client made, most first.
2285pub fn ledger_clients(db: &Connection) -> Result<Vec<(String, i64)>> {
2286    let mut stmt = db.prepare(
2287        "SELECT client, COUNT(*) FROM retrievals GROUP BY client ORDER BY COUNT(*) DESC, client",
2288    )?;
2289    let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
2290    Ok(rows.collect::<Result<Vec<_>, _>>()?)
2291}
2292
2293/// Re-walk the chain and return the id of the first row that does not verify.
2294///
2295/// `None` means the ledger is intact.
2296pub fn ledger_break(db: &Connection) -> Result<Option<i64>> {
2297    let mut stmt = db.prepare(
2298        "SELECT id, at, client, query, hits, micros, excerpt_tokens, whole_file_tokens, prev, hash,
2299                session, tool, stale_hits, tokenizer
2300         FROM retrievals ORDER BY id",
2301    )?;
2302    let mut rows = stmt.query([])?;
2303    let mut expected = String::new();
2304    while let Some(r) = rows.next()? {
2305        let (id, at): (i64, i64) = (r.get(0)?, r.get(1)?);
2306        let client: String = r.get(2)?;
2307        let query: String = r.get(3)?;
2308        let (hits, micros, excerpt, whole): (i64, i64, i64, i64) =
2309            (r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?);
2310        let prev: String = r.get(8)?;
2311        let hash: String = r.get(9)?;
2312        if prev != expected {
2313            return Ok(Some(id));
2314        }
2315        // `tool` is NULL on every row written before 0.15.0 and set on every
2316        // row written since, which is what says which formula wrote this hash.
2317        let tool: Option<String> = r.get(11)?;
2318        let recomputed = match tool {
2319            Some(tool) => {
2320                let session: String = r.get(10)?;
2321                let stale: i64 = r.get(12)?;
2322                let tokenizer: String = r.get(13)?;
2323                chain_hash(
2324                    &prev,
2325                    at,
2326                    &NewRetrieval {
2327                        client: &client,
2328                        session: &session,
2329                        tool: &tool,
2330                        query: &query,
2331                        hits,
2332                        micros,
2333                        excerpt_tokens: excerpt,
2334                        whole_file_tokens: whole,
2335                        stale_hits: stale,
2336                        tokenizer: &tokenizer,
2337                        // Not covered by the hash; see `chain_hash`.
2338                        query_id: "",
2339                    },
2340                )
2341            }
2342            None => legacy_chain_hash(&prev, at, &client, &query, hits, micros, excerpt, whole),
2343        };
2344        if recomputed != hash {
2345            return Ok(Some(id));
2346        }
2347        expected = hash;
2348    }
2349    Ok(None)
2350}
2351
2352/// The total bytes of every file a grep for `name` would have to read.
2353///
2354/// The honest denominator for a graph answer's saving. A search's denominator
2355/// is the files its hits came from; a graph answer has no hits, but it does
2356/// have an answer that would otherwise have been assembled by grepping for the
2357/// name and reading what came back. These are those files: the ones that
2358/// define the name, and the ones whose code points at it.
2359pub fn grep_cost(db: &Connection, name: &str) -> Result<i64> {
2360    Ok(db.query_row(
2361        "SELECT COALESCE(SUM(bytes), 0) FROM files WHERE id IN (
2362            SELECT s.file_id FROM symbols s WHERE s.name = ?1
2363            UNION
2364            SELECT s.file_id FROM symbols s JOIN edges e ON e.src = s.id WHERE e.dst = ?1
2365         )",
2366        params![name],
2367        |r| r.get(0),
2368    )?)
2369}
2370
2371/// Whether this store has `path` indexed.
2372///
2373/// Compared the way every filter compares a path — normalised separators,
2374/// lowercased — so a Windows store answers the same question a unix one does
2375/// about the same file. The steering hook's ledger row depends on it: a raw
2376/// read is only a refund against the store that could have answered it.
2377pub fn holds_path(db: &Connection, path: &str) -> Result<bool> {
2378    // Canonical, because that is the form a store records and a caller may hand
2379    // over whatever the client said — a relative path, a symlink, or on macOS a
2380    // `/var` that is really `/private/var`.
2381    let canonical = crate::canonical(std::path::Path::new(path));
2382    let wanted = canonical
2383        .to_string_lossy()
2384        .to_lowercase()
2385        .replace('\\', "/");
2386    let sql = format!("SELECT 1 FROM files f WHERE {GLOB_PATH} = ?1 LIMIT 1");
2387    Ok(db
2388        .query_row(&sql, params![wanted], |_| Ok(()))
2389        .optional()?
2390        .is_some())
2391}
2392
2393/// One definition's span and identity: `(start, end, name, kind)`.
2394pub type SymbolSpan = (u32, u32, String, String);
2395
2396/// Every symbol defined in each of `paths`.
2397///
2398/// What turns a line range into a place a person recognises. A hit that says
2399/// `src/store.rs:1041-1090` makes a reader open the file to find out what is
2400/// there; one that says `record_retrieval` does not.
2401///
2402/// Read per file rather than per hit, because eight hits in one file are one
2403/// question about that file.
2404pub fn symbols_in_files(
2405    db: &Connection,
2406    paths: &[String],
2407) -> Result<std::collections::HashMap<String, Vec<SymbolSpan>>> {
2408    let mut out: std::collections::HashMap<String, Vec<SymbolSpan>> = Default::default();
2409    if paths.is_empty() {
2410        return Ok(out);
2411    }
2412    let holes = vec!["?"; paths.len()].join(", ");
2413    let sql = format!(
2414        "SELECT f.path, s.start_line, s.end_line, s.name, s.kind
2415         FROM symbols s JOIN files f ON f.id = s.file_id
2416         WHERE f.path IN ({holes}) AND s.kind != 'module'
2417         ORDER BY f.path, s.start_line"
2418    );
2419    let mut stmt = db.prepare(&sql)?;
2420    let args = paths.iter().cloned().map(Value::Text);
2421    let mut rows = stmt.query(rusqlite::params_from_iter(args))?;
2422    while let Some(r) = rows.next()? {
2423        let path: String = r.get(0)?;
2424        out.entry(path)
2425            .or_default()
2426            .push((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?));
2427    }
2428    Ok(out)
2429}
2430
2431/// `(bytes, indexed_at)` for each of `paths` the store knows.
2432///
2433/// The cheap half of a freshness check: what the store recorded about a file
2434/// when it read it. The other half is one `stat` of the file as it is now.
2435pub fn file_stamps(
2436    db: &Connection,
2437    paths: &[String],
2438) -> Result<std::collections::HashMap<String, (i64, i64)>> {
2439    if paths.is_empty() {
2440        return Ok(Default::default());
2441    }
2442    let holes = vec!["?"; paths.len()].join(", ");
2443    let sql = format!("SELECT path, bytes, indexed_at FROM files WHERE path IN ({holes})");
2444    let mut stmt = db.prepare(&sql)?;
2445    let args = paths.iter().cloned().map(Value::Text);
2446    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
2447        Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?)))
2448    })?;
2449    Ok(rows.collect::<Result<std::collections::HashMap<_, _>, _>>()?)
2450}
2451
2452/// The newest `indexed_at` this store holds, or `None` when it holds nothing.
2453///
2454/// The store's own answer to "last write", which is what the Stores page's
2455/// column says it shows. The daemon used to answer it from a counter that
2456/// started at zero every time it launched, so a store with 438 files in it read
2457/// `never` until something wrote to it again — finding 1.7 of the 2026-09-17
2458/// drive.
2459pub fn last_write(db: &Connection) -> Result<Option<i64>> {
2460    let at: Option<i64> = db.query_row("SELECT MAX(indexed_at) FROM files", [], |r| r.get(0))?;
2461    Ok(at)
2462}
2463
2464/// `(symbols, edges)` — the graph's size, for `stats` and the portal.
2465pub fn graph_stats(db: &Connection) -> Result<(i64, i64)> {
2466    let symbols: i64 = db.query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))?;
2467    let edges: i64 = db.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
2468    Ok((symbols, edges))
2469}
2470
2471/// `(files, chunks, indexed bytes)`
2472/// Whether this store can still be read, as cheaply as that can be asked.
2473///
2474/// One row out of each of the two large tables, by the rowid index. It exists
2475/// because the obvious probe — `stats()` below — is three full scans, and this
2476/// runs once per store on every aggregating read, which includes every search.
2477/// Measured on a 10 390-chunk store: `stats()` 2.65 ms, this 0.24 ms, and the
2478/// gap widens linearly because one is O(n) and the other is O(log n).
2479///
2480/// It is not weaker for being cheaper. What it has to catch is a database that
2481/// opened and cannot be read now — truncated by a killed index run, on a volume
2482/// that was unplugged — and a btree descent into a page that is no longer there
2483/// fails exactly as a scan would. What it deliberately does not do is read
2484/// `meta` and call that proof: `Semlith::open` already read `meta`, and page one
2485/// of a truncated file is usually the part that survived.
2486///
2487/// An empty table is not a failure: a store with nothing indexed yet is a store
2488/// that reads fine and holds nothing.
2489pub fn readable(db: &Connection) -> Result<()> {
2490    for table in ["files", "chunks"] {
2491        let sql = format!("SELECT id FROM {table} ORDER BY id DESC LIMIT 1");
2492        let mut stmt = db.prepare(&sql)?;
2493        let mut rows = stmt.query([])?;
2494        // Stepped rather than collected: the error this is looking for arrives
2495        // on the step, and `Option` is the empty-table case rather than a fault.
2496        let _: Option<i64> = rows.next()?.map(|r| r.get(0)).transpose()?;
2497    }
2498    Ok(())
2499}
2500
2501pub fn stats(db: &Connection) -> Result<(i64, i64, i64)> {
2502    let files: i64 = db.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))?;
2503    let chunks: i64 = db.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
2504    let bytes: i64 = db.query_row("SELECT COALESCE(SUM(bytes), 0) FROM files", [], |r| {
2505        r.get(0)
2506    })?;
2507    Ok((files, chunks, bytes))
2508}
2509
2510/// Everything the `Inside the index` page shows about one store.
2511///
2512/// Measured, not estimated — that is the page's own claim about itself and so
2513/// it is the contract for this function. Where a figure cannot be measured
2514/// from what the store holds, it is not here; the page says what it does not
2515/// know rather than printing a plausible number.
2516///
2517/// One pass over `chunks` is the expensive part: the text of every chunk is
2518/// read to count lines, words, characters and blanks. A 130k-chunk store is
2519/// tens of megabytes, which is a second or so — this runs when the page is
2520/// opened, not on every write.
2521#[derive(Debug, Clone, Default, serde::Serialize)]
2522pub struct Corpus {
2523    pub files: i64,
2524    pub chunks: i64,
2525    pub bytes: i64,
2526    /// Lines covered by chunks. A file's lines are `MAX(end_line)` over its
2527    /// chunks, which is the file's length wherever it was chunked whole.
2528    pub lines: i64,
2529    pub words: i64,
2530    pub characters: i64,
2531    pub blank_lines: i64,
2532    /// Lines whose first non-space characters open a comment in that file's
2533    /// language. A heuristic over a short table of openers, named as one on
2534    /// the page.
2535    pub comment_lines: i64,
2536    /// Per language, by line: the mix the page draws as a stacked bar.
2537    pub languages: Vec<LanguageLines>,
2538    /// Files by the kind of thing they are, for the prose panel.
2539    pub kinds: Vec<KindCount>,
2540    pub longest_file: Option<FileLines>,
2541    /// The most folders any indexed path descends through.
2542    pub deepest_path: i64,
2543    /// Unix seconds of the first and last `indexed_at` in the store.
2544    pub first_indexed: i64,
2545    pub last_indexed: i64,
2546    /// Chunks added per month, oldest first, as `("2026-09", 9610)`.
2547    pub months: Vec<MonthCount>,
2548    /// One vector per chunk, of this many numbers each.
2549    pub vector_dim: i64,
2550    pub symbols: i64,
2551    pub edges: i64,
2552    /// Edges by tier, in the order the graph rail lists them. Decided by
2553    /// `coverage_by_language`, which is the one rule for this.
2554    pub tiers: Vec<KindCount>,
2555    /// Edges naming something no definition in this store satisfies, and the
2556    /// names most often behind that.
2557    pub unresolved: i64,
2558    pub unresolved_names: Vec<KindCount>,
2559    /// Names defined more than once, and the worst of them.
2560    pub ambiguous_names: i64,
2561    pub ambiguous_worst: Vec<KindCount>,
2562    /// Languages this store holds, and how many of them carry an edge.
2563    pub languages_with_edges: i64,
2564    /// The median milliseconds of the retrievals the ledger recorded, or zero
2565    /// when it has recorded none: a claim about this machine rather than a
2566    /// benchmark from somewhere else.
2567    pub median_query_ms: i64,
2568}
2569
2570#[derive(Debug, Clone, Default, serde::Serialize)]
2571pub struct LanguageLines {
2572    pub language: String,
2573    pub lines: i64,
2574    pub files: i64,
2575}
2576
2577#[derive(Debug, Clone, Default, serde::Serialize)]
2578pub struct KindCount {
2579    pub name: String,
2580    pub count: i64,
2581}
2582
2583#[derive(Debug, Clone, Default, serde::Serialize)]
2584pub struct FileLines {
2585    pub path: String,
2586    pub lines: i64,
2587}
2588
2589#[derive(Debug, Clone, Default, serde::Serialize)]
2590pub struct MonthCount {
2591    /// `YYYY-MM`, in UTC, because that is what SQLite's `strftime` gives and a
2592    /// month boundary in the reader's zone is not worth a time-zone database.
2593    pub month: String,
2594    pub chunks: i64,
2595}
2596
2597/// A line's comment openers, by the language the path says it is.
2598///
2599/// Short on purpose. It is here to answer "how much of this corpus is prose
2600/// inside code", and getting `//` and `#` right answers that for nearly every
2601/// file anybody indexes. A language missing from it contributes no comment
2602/// lines rather than a wrong count.
2603fn comment_openers(language: &str) -> &'static [&'static str] {
2604    // Lowercased, because `filter::Language::name` is already lowercase —
2605    // `rust`, not `Rust`. Matching the capitalised spellings found nothing and
2606    // reported a corpus with no comments in it at all.
2607    match language.to_ascii_lowercase().as_str() {
2608        "rust" | "c" | "c++" | "c#" | "go" | "java" | "javascript" | "typescript" | "tsx"
2609        | "jsx" | "swift" | "kotlin" | "scala" | "php" | "dart" | "zig" | "css" => {
2610            &["//", "/*", "*"]
2611        }
2612        "python" | "ruby" | "shell" | "bash" | "perl" | "r" | "yaml" | "toml" | "makefile"
2613        | "dockerfile" | "elixir" | "nix" => &["#"],
2614        "sql" | "lua" | "haskell" => &["--"],
2615        "html" | "xml" | "markdown" => &["<!--"],
2616        "lisp" | "clojure" => &[";"],
2617        _ => &[],
2618    }
2619}
2620
2621/// Measure one store, for the `Inside the index` page.
2622pub fn corpus(db: &Connection, language_of: impl Fn(&str) -> String) -> Result<Corpus> {
2623    let (files, chunks, bytes) = stats(db)?;
2624    let mut out = Corpus {
2625        files,
2626        chunks,
2627        bytes,
2628        vector_dim: 384,
2629        ..Default::default()
2630    };
2631
2632    // One pass over the chunks, joined to the path so the comment rule knows
2633    // which language it is reading. Everything counted per file first, so
2634    // "lines" is the file's own length rather than the sum of its chunks'
2635    // spans — chunks overlap.
2636    let mut per_file: std::collections::HashMap<i64, (String, i64)> =
2637        std::collections::HashMap::new();
2638    {
2639        let mut q = db.prepare(
2640            "SELECT c.file_id, f.path, c.end_line, c.text
2641               FROM chunks c JOIN files f ON f.id = c.file_id",
2642        )?;
2643        let mut rows = q.query([])?;
2644        while let Some(row) = rows.next()? {
2645            let file_id: i64 = row.get(0)?;
2646            let path: String = row.get(1)?;
2647            let end: i64 = row.get(2)?;
2648            let text: String = row.get(3)?;
2649            let language = language_of(&path);
2650            let openers = comment_openers(&language);
2651            out.characters += text.chars().count() as i64;
2652            out.words += text.split_whitespace().count() as i64;
2653            for line in text.lines() {
2654                let trimmed = line.trim_start();
2655                if trimmed.is_empty() {
2656                    out.blank_lines += 1;
2657                } else if openers.iter().any(|o| trimmed.starts_with(o)) {
2658                    out.comment_lines += 1;
2659                }
2660            }
2661            let slot = per_file.entry(file_id).or_insert((path, 0));
2662            slot.1 = slot.1.max(end);
2663        }
2664    }
2665
2666    let mut by_language: std::collections::HashMap<String, (i64, i64)> =
2667        std::collections::HashMap::new();
2668    for (path, lines) in per_file.values() {
2669        out.lines += lines;
2670        let language = language_of(path);
2671        let name = if language.is_empty() {
2672            "other".to_string()
2673        } else {
2674            language
2675        };
2676        let slot = by_language.entry(name).or_insert((0, 0));
2677        slot.0 += lines;
2678        slot.1 += 1;
2679        if out.longest_file.as_ref().is_none_or(|f| f.lines < *lines) {
2680            out.longest_file = Some(FileLines {
2681                path: path.clone(),
2682                lines: *lines,
2683            });
2684        }
2685        let depth = path.trim_matches('/').matches('/').count() as i64;
2686        out.deepest_path = out.deepest_path.max(depth);
2687    }
2688    out.languages = by_language
2689        .into_iter()
2690        .map(|(language, (lines, files))| LanguageLines {
2691            language,
2692            lines,
2693            files,
2694        })
2695        .collect();
2696    out.languages
2697        .sort_by_key(|row| std::cmp::Reverse(row.lines));
2698
2699    out.kinds = counted(
2700        db,
2701        "SELECT CASE
2702                  WHEN lower(path) LIKE '%.pdf' THEN 'PDF'
2703                  WHEN lower(path) LIKE '%.pptx' OR lower(path) LIKE '%.key' THEN 'Slide deck'
2704                  WHEN lower(path) LIKE '%.xlsx' OR lower(path) LIKE '%.csv' THEN 'Spreadsheet'
2705                  WHEN lower(path) LIKE '%.ipynb' THEN 'Notebook'
2706                  WHEN lower(path) LIKE '%.docx' THEN 'Document'
2707                  WHEN lower(path) LIKE '%.png' OR lower(path) LIKE '%.jpg'
2708                    OR lower(path) LIKE '%.jpeg' OR lower(path) LIKE '%.webp' THEN 'Image'
2709                  ELSE 'Text and code'
2710                END AS k,
2711                COUNT(*)
2712           FROM files GROUP BY k ORDER BY COUNT(*) DESC",
2713    )?;
2714
2715    let span: (i64, i64) = db.query_row(
2716        "SELECT COALESCE(MIN(indexed_at), 0), COALESCE(MAX(indexed_at), 0) FROM files",
2717        [],
2718        |r| Ok((r.get(0)?, r.get(1)?)),
2719    )?;
2720    out.first_indexed = span.0;
2721    out.last_indexed = span.1;
2722
2723    {
2724        let mut q = db.prepare(
2725            "SELECT strftime('%Y-%m', f.indexed_at, 'unixepoch') AS m, COUNT(c.id)
2726               FROM files f JOIN chunks c ON c.file_id = f.id
2727              GROUP BY m ORDER BY m",
2728        )?;
2729        let mut rows = q.query([])?;
2730        while let Some(row) = rows.next()? {
2731            out.months.push(MonthCount {
2732                month: row.get(0)?,
2733                chunks: row.get(1)?,
2734            });
2735        }
2736    }
2737
2738    out.symbols = db.query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))?;
2739    out.edges = db.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
2740
2741    /* The four tiers, from `coverage_by_language`, which is the one place that
2742     * decides them.
2743     *
2744     * Grouping `edges.confidence` stood here and is a different question: the
2745     * column holds `extracted` or `inferred`, and whether an inferred edge is
2746     * resolved, ambiguous or unresolved depends on how many definitions of its
2747     * target this store holds. So the page read `resolved 0 · ambiguous 0`
2748     * directly above a coverage table reading 2380 and 2158 -- two answers to
2749     * one question, on one page, from one store.
2750     *
2751     * `languages_with_edges` comes from the same pass for the same reason. */
2752    let coverage = coverage_by_language(db)?;
2753    let mut tiers = [0i64; 4];
2754    for row in &coverage {
2755        tiers[0] += row.extracted as i64;
2756        tiers[1] += row.resolved as i64;
2757        tiers[2] += row.ambiguous as i64;
2758        tiers[3] += row.unresolved as i64;
2759        if row.extracted + row.resolved + row.ambiguous > 0 {
2760            out.languages_with_edges += 1;
2761        }
2762    }
2763    out.tiers = ["extracted", "resolved", "ambiguous", "unresolved"]
2764        .iter()
2765        .zip(tiers)
2766        .map(|(name, count)| KindCount {
2767            name: (*name).to_string(),
2768            count,
2769        })
2770        .collect();
2771    out.unresolved = tiers[3];
2772    let (names, _) = unresolved_targets(db, 7)?;
2773    out.unresolved_names = names
2774        .into_iter()
2775        .map(|(name, count)| KindCount { name, count })
2776        .collect();
2777    out.ambiguous_names = db.query_row(
2778        "SELECT COUNT(*) FROM (SELECT name FROM symbols GROUP BY name HAVING COUNT(*) > 1)",
2779        [],
2780        |r| r.get(0),
2781    )?;
2782    out.ambiguous_worst = counted(
2783        db,
2784        "SELECT name, COUNT(*) c FROM symbols GROUP BY name HAVING c > 1
2785          ORDER BY c DESC, name LIMIT 5",
2786    )?;
2787
2788    // The middle of what this machine actually measured. `ms` is recorded per
2789    // retrieval, so this is a fact about this store rather than a benchmark.
2790    out.median_query_ms = db
2791        .query_row(
2792            // `micros`, which is what the column is called. Asking for `ms`
2793            // did not fail loudly — the row read errored and the fallback
2794            // reported a store that had never been queried.
2795            "SELECT micros / 1000 FROM retrievals ORDER BY micros
2796              LIMIT 1 OFFSET (SELECT COUNT(*) FROM retrievals) / 2",
2797            [],
2798            |r| r.get(0),
2799        )
2800        .unwrap_or(0);
2801
2802    Ok(out)
2803}
2804
2805/// A two-column `name, count` query, as the list the page draws from it.
2806fn counted(db: &Connection, sql: &str) -> Result<Vec<KindCount>> {
2807    let mut q = db.prepare(sql)?;
2808    let mut rows = q.query([])?;
2809    let mut out = Vec::new();
2810    while let Some(row) = rows.next()? {
2811        out.push(KindCount {
2812            name: row.get(0)?,
2813            count: row.get(1)?,
2814        });
2815    }
2816    Ok(out)
2817}
2818
2819/// One session's whole ledger, as the per-session table reads it.
2820#[derive(Debug, Clone, Default)]
2821pub struct SessionRow {
2822    /// The session id the client sent at handshake. Empty for rows written
2823    /// before 0.15.0, which is said rather than guessed at.
2824    pub session: String,
2825    pub client: String,
2826    /// When the session's first and last recorded retrieval happened.
2827    pub first: i64,
2828    pub last: i64,
2829    pub retrievals: i64,
2830    /// Retrievals that found nothing. Recorded and credited nothing — a
2831    /// ledger that remembers only its successes is a marketing document.
2832    pub zero_hit: i64,
2833    pub excerpt_tokens: i64,
2834    pub whole_file_tokens: i64,
2835    /// Whole-file less excerpt, over the rows that found something.
2836    pub net: i64,
2837    /// Rows counted with the store's own tokenizer rather than by the
2838    /// four-characters fallback. The tier of this row's figures.
2839    pub measured: i64,
2840}
2841
2842impl SessionRow {
2843    /// `measured` when every credited row in the session was counted by a
2844    /// model's own tokenizer, `modelled` otherwise. Never averaged: two rows
2845    /// counted two ways are not one number.
2846    pub fn tier(&self) -> &'static str {
2847        if self.measured == self.retrievals && self.retrievals > 0 {
2848            "measured"
2849        } else {
2850            "modelled"
2851        }
2852    }
2853}
2854
2855/// Every session this store recorded, newest last-seen first.
2856///
2857/// Grouped in SQL rather than in the page, for the reason the totals are:
2858/// one search over six stores writes a row in each, and a browser adding
2859/// them up is a second opinion about a number the store can state.
2860pub fn ledger_sessions(db: &Connection, limit: usize) -> Result<Vec<SessionRow>> {
2861    let mut stmt = db.prepare(
2862        "SELECT COALESCE(session, ''), client,
2863                MIN(at), MAX(at), COUNT(*),
2864                SUM(CASE WHEN hits = 0 THEN 1 ELSE 0 END),
2865                SUM(excerpt_tokens), SUM(whole_file_tokens),
2866                SUM(CASE WHEN hits > 0 THEN whole_file_tokens - excerpt_tokens ELSE 0 END),
2867                SUM(CASE WHEN tokenizer IS NOT NULL AND tokenizer <> 'chars4' THEN 1 ELSE 0 END)
2868         FROM retrievals
2869         GROUP BY COALESCE(session, ''), client
2870         ORDER BY MAX(at) DESC
2871         LIMIT ?1",
2872    )?;
2873    let rows = stmt.query_map([limit as i64], |r| {
2874        Ok(SessionRow {
2875            session: r.get(0)?,
2876            client: r.get(1)?,
2877            first: r.get(2)?,
2878            last: r.get(3)?,
2879            retrievals: r.get(4)?,
2880            zero_hit: r.get(5)?,
2881            excerpt_tokens: r.get(6)?,
2882            whole_file_tokens: r.get(7)?,
2883            net: r.get(8)?,
2884            measured: r.get(9)?,
2885        })
2886    })?;
2887    Ok(rows.collect::<rusqlite::Result<_>>()?)
2888}
2889
2890/// Warm query latency as the ledger recorded it: p50 and p95, in microseconds.
2891///
2892/// From the rows themselves rather than from a benchmark, so the figure is
2893/// what this machine actually served rather than what it can serve.
2894pub fn ledger_latency(db: &Connection) -> Result<(i64, i64)> {
2895    let mut stmt = db.prepare("SELECT micros FROM retrievals ORDER BY micros")?;
2896    let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?;
2897    let all: Vec<i64> = rows.collect::<rusqlite::Result<_>>()?;
2898    if all.is_empty() {
2899        return Ok((0, 0));
2900    }
2901    let at = |q: f64| all[((all.len() as f64 - 1.0) * q).round() as usize];
2902    Ok((at(0.50), at(0.95)))
2903}
2904
2905/// The hash of the newest ledger row: what `--verify` checks the chain to.
2906pub fn ledger_head(db: &Connection) -> Result<Option<String>> {
2907    let hash: Option<String> = db
2908        .query_row(
2909            "SELECT hash FROM retrievals ORDER BY id DESC LIMIT 1",
2910            [],
2911            |r| r.get(0),
2912        )
2913        .optional()?;
2914    Ok(hash)
2915}
2916
2917/// Queries that found nothing, commonest first.
2918///
2919/// The gap list: a question an agent asked that this corpus could not answer
2920/// is either a missing document or a name nobody uses in the words they
2921/// searched with.
2922pub fn ledger_zero_hit_queries(db: &Connection, limit: usize) -> Result<Vec<(String, i64)>> {
2923    ledger_zero_hit_queries_since(db, 0, limit)
2924}
2925
2926/// The questions that found nothing, at or after `since`.
2927pub fn ledger_zero_hit_queries_since(
2928    db: &Connection,
2929    since: i64,
2930    limit: usize,
2931) -> Result<Vec<(String, i64)>> {
2932    let mut stmt = db.prepare(
2933        "SELECT query, COUNT(*) AS n FROM retrievals WHERE hits = 0 AND at >= ?1
2934         GROUP BY query ORDER BY n DESC, query LIMIT ?2",
2935    )?;
2936    let rows = stmt.query_map(rusqlite::params![since, limit as i64], |r| {
2937        Ok((r.get(0)?, r.get(1)?))
2938    })?;
2939    Ok(rows.collect::<rusqlite::Result<_>>()?)
2940}
2941
2942/// One recorded retrieval, as the access report attaches them.
2943#[derive(Debug, Clone)]
2944pub struct RetrievalRow {
2945    pub at: i64,
2946    pub client: String,
2947    pub tool: String,
2948    pub query: String,
2949    pub hits: i64,
2950    pub excerpt_tokens: i64,
2951    pub whole_file_tokens: i64,
2952}
2953
2954/// Every retrieval since `since`, newest first.
2955///
2956/// `ledger_sessions` above aggregates these into one row per session, which is
2957/// what the access report shows by default. This is the same rows unrolled, for
2958/// the report's `Attach retrieved excerpts` toggle: a session line says an agent
2959/// asked forty times, and an auditor's question is which forty.
2960///
2961/// `tool` is NULL on every row written before 0.15.0 and is reported as `—`
2962/// rather than guessed — those rows are real retrievals and dropping them would
2963/// make the attachment disagree with the session counts above it.
2964pub fn ledger_retrievals(db: &Connection, since: i64, limit: usize) -> Result<Vec<RetrievalRow>> {
2965    let mut stmt = db.prepare(
2966        "SELECT at, client, COALESCE(tool, ''), query, hits, excerpt_tokens, whole_file_tokens
2967         FROM retrievals WHERE at >= ?1 ORDER BY at DESC, id DESC LIMIT ?2",
2968    )?;
2969    let rows = stmt.query_map(rusqlite::params![since, limit as i64], |r| {
2970        Ok(RetrievalRow {
2971            at: r.get(0)?,
2972            client: r.get(1)?,
2973            tool: r.get(2)?,
2974            query: r.get(3)?,
2975            hits: r.get(4)?,
2976            excerpt_tokens: r.get(5)?,
2977            whole_file_tokens: r.get(6)?,
2978        })
2979    })?;
2980    Ok(rows.collect::<rusqlite::Result<_>>()?)
2981}
2982
2983/// Files this store read since `since`, newest first, with when it read them.
2984pub fn files_indexed_since(
2985    db: &Connection,
2986    since: i64,
2987    limit: usize,
2988) -> Result<Vec<(String, i64)>> {
2989    let mut stmt = db.prepare(
2990        "SELECT path, indexed_at FROM files WHERE indexed_at >= ?1
2991         ORDER BY indexed_at DESC LIMIT ?2",
2992    )?;
2993    let rows = stmt.query_map([since, limit as i64], |r| Ok((r.get(0)?, r.get(1)?)))?;
2994    Ok(rows.collect::<rusqlite::Result<_>>()?)
2995}
2996
2997/// One settled dependency edge, by name, for the community pass.
2998///
2999/// Names rather than ids, because `edges.dst` is a name and the whole point
3000/// of the pass is to group names. Only edges whose target the store actually
3001/// defines are returned: an edge into a dependency that was never indexed
3002/// cannot join two communities of this corpus.
3003pub struct NamedEdge {
3004    pub from: String,
3005    pub to: String,
3006}
3007
3008/// Every `calls` and `imports` edge whose target this store defines, up to
3009/// `limit`, with the total so a reader is told when the list was cut.
3010///
3011/// This is the one whole-graph read in the crate and it is bounded for that
3012/// reason. Everything else walks one hop at a time to keep peak memory flat
3013/// as a corpus grows; communities are a property of the whole graph and
3014/// cannot be computed a hop at a time, so the bound is the substitute — past
3015/// it the panel says `Shown N of M` rather than quietly clustering a slice.
3016pub fn community_edges(db: &Connection, limit: usize) -> Result<(Vec<NamedEdge>, i64)> {
3017    let target = crate::graph::not_navigational("s.kind");
3018    // `extracted` and `inferred` are the stored values; `resolved` and
3019    // `ambiguous` are computed per query and never written, so "settled" here
3020    // means an extracted edge or a bare-name match onto exactly one
3021    // definition. A name with several definitions is not evidence that two
3022    // subsystems are joined.
3023    let sql = format!(
3024        "SELECT src.name, s.name
3025         FROM symbols src
3026         JOIN edges e ON e.src = src.id
3027         JOIN symbols s ON s.name = e.dst AND {target}
3028         WHERE e.kind IN ('calls', 'imports')
3029           AND src.name <> s.name
3030           AND (SELECT COUNT(*) FROM symbols d WHERE d.name = e.dst AND {}) = 1
3031         LIMIT ?1",
3032        crate::graph::not_navigational("d.kind")
3033    );
3034    let mut stmt = db.prepare(&sql)?;
3035    let rows = stmt.query_map([limit as i64], |r| {
3036        Ok(NamedEdge {
3037            from: r.get(0)?,
3038            to: r.get(1)?,
3039        })
3040    })?;
3041    let edges: Vec<NamedEdge> = rows.collect::<rusqlite::Result<_>>()?;
3042    let total: i64 = db.query_row(
3043        "SELECT COUNT(*) FROM edges WHERE kind IN ('calls', 'imports')",
3044        [],
3045        |r| r.get(0),
3046    )?;
3047    Ok((edges, total))
3048}
3049
3050/// Where each of these names is defined, for the hub lines under a community.
3051pub fn where_defined(db: &Connection, names: &[String]) -> Result<Vec<(String, String, u32)>> {
3052    if names.is_empty() {
3053        return Ok(Vec::new());
3054    }
3055    let holes = vec!["?"; names.len()].join(", ");
3056    let sql = format!(
3057        "SELECT s.name, f.path, s.start_line
3058         FROM symbols s JOIN files f ON f.id = s.file_id
3059         WHERE s.name IN ({holes})
3060         ORDER BY f.path, s.start_line"
3061    );
3062    let mut stmt = db.prepare(&sql)?;
3063    let binds: Vec<Value> = names.iter().map(|n| Value::Text(n.clone())).collect();
3064    let rows = stmt.query_map(rusqlite::params_from_iter(binds), |r| {
3065        Ok((r.get(0)?, r.get(1)?, r.get(2)?))
3066    })?;
3067    Ok(rows.collect::<rusqlite::Result<_>>()?)
3068}
3069
3070/// Call targets no definition in this store satisfies, commonest first.
3071///
3072/// The names a reader has to recognise as "outside this corpus" before they
3073/// read an absent edge as a missing call. Counted rather than listed in full:
3074/// on a store that indexes application code every standard-library call is
3075/// one of these, and the list is long and uninteresting past the first few.
3076pub fn unresolved_targets(db: &Connection, limit: usize) -> Result<(Vec<(String, i64)>, i64)> {
3077    let target = crate::graph::not_navigational("d.kind");
3078    let sql = format!(
3079        "SELECT e.dst, COUNT(*) AS n
3080         FROM edges e
3081         WHERE e.kind IN ('calls', 'imports')
3082           AND NOT EXISTS (SELECT 1 FROM symbols d WHERE d.name = e.dst AND {target})
3083         GROUP BY e.dst
3084         ORDER BY n DESC, e.dst
3085         LIMIT ?1"
3086    );
3087    let mut stmt = db.prepare(&sql)?;
3088    let rows = stmt.query_map([limit as i64], |r| Ok((r.get(0)?, r.get(1)?)))?;
3089    let top: Vec<(String, i64)> = rows.collect::<rusqlite::Result<_>>()?;
3090    let distinct: i64 = db.query_row(
3091        &format!(
3092            "SELECT COUNT(*) FROM (SELECT e.dst FROM edges e
3093             WHERE e.kind IN ('calls', 'imports')
3094               AND NOT EXISTS (SELECT 1 FROM symbols d WHERE d.name = e.dst AND {target})
3095             GROUP BY e.dst)"
3096        ),
3097        [],
3098        |r| r.get(0),
3099    )?;
3100    Ok((top, distinct))
3101}
3102
3103/// How many names this store defines more than once.
3104///
3105/// The population of every ambiguous edge: a walk refuses to cross one of
3106/// these, so the count is what a reader needs to know before reading "not
3107/// connected" as "nothing calls it".
3108pub fn names_with_several_definitions(db: &Connection) -> Result<i64> {
3109    let target = crate::graph::not_navigational("s.kind");
3110    db.query_row(
3111        &format!(
3112            "SELECT COUNT(*) FROM (SELECT s.name FROM symbols s WHERE {target}
3113             GROUP BY s.name HAVING COUNT(*) > 1)"
3114        ),
3115        [],
3116        |r| r.get(0),
3117    )
3118    .map_err(Into::into)
3119}
3120
3121/// Chunks by the month their file was indexed, oldest first.
3122///
3123/// Indexed rather than written: the store records when it read a file, not
3124/// when somebody wrote it, and labelling this "chunks per month" without
3125/// saying which would be a claim about a repository's history that the store
3126/// cannot make.
3127pub fn chunks_by_month(db: &Connection) -> Result<Vec<(String, i64)>> {
3128    let mut stmt = db.prepare(
3129        "SELECT strftime('%Y-%m', f.indexed_at, 'unixepoch') AS month, COUNT(c.id)
3130         FROM chunks c JOIN files f ON f.id = c.file_id
3131         GROUP BY month ORDER BY month",
3132    )?;
3133    let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
3134    Ok(rows.collect::<rusqlite::Result<_>>()?)
3135}
3136
3137#[cfg(test)]
3138mod tests {
3139    use super::*;
3140
3141    fn sym(name: &str) -> crate::graph::Symbol {
3142        crate::graph::Symbol {
3143            kind: "function".to_string(),
3144            name: name.to_string(),
3145            qualified: name.to_string(),
3146            start_line: 1,
3147            end_line: 2,
3148        }
3149    }
3150
3151    fn at(mut s: crate::graph::Symbol, start: u32, end: u32) -> crate::graph::Symbol {
3152        s.start_line = start;
3153        s.end_line = end;
3154        s
3155    }
3156
3157    /// Build a store with one file, one chunk and one symbol, and return the
3158    /// connection plus the symbol's id.
3159    fn one_symbol(db: &Connection, path: &str, name: &str) -> i64 {
3160        db.execute_batch(SCHEMA).unwrap();
3161        db.pragma_update(None, "foreign_keys", "ON").unwrap();
3162        let file_id = insert_file(db, path, "h", 1, 0).unwrap();
3163        let chunk_id = insert_chunk(db, file_id, 0, 1, 2, name).unwrap();
3164        insert_symbol(db, file_id, Some(chunk_id), &sym(name)).unwrap()
3165    }
3166
3167    /// Forgetting a file takes its symbols and its outgoing edges with it.
3168    ///
3169    /// The cascade does this, not the caller — which is why it is worth a test:
3170    /// a missing `ON DELETE CASCADE` would leave rows pointing at a file id
3171    /// SQLite is free to reissue.
3172    #[test]
3173    fn deleting_a_file_deletes_its_symbols_and_its_outgoing_edges() {
3174        let db = Connection::open_in_memory().unwrap();
3175        let caller = one_symbol(&db, "a.rs", "caller");
3176        insert_edge(&db, caller, "callee", "calls", "inferred", None, None).unwrap();
3177        assert_eq!(graph_stats(&db).unwrap(), (1, 1));
3178
3179        delete_file(&db, "a.rs", 0).unwrap();
3180        assert_eq!(
3181            graph_stats(&db).unwrap(),
3182            (0, 0),
3183            "the file's symbols and the edges leaving them both cascade"
3184        );
3185    }
3186
3187    /// Re-indexing the file an edge points *into* must not delete the edge.
3188    ///
3189    /// This is the whole reason `edges.dst` is a name and not an id. With an id
3190    /// there, re-extracting `b.rs` would reissue its symbol ids and orphan
3191    /// every edge from `a.rs` into it — the graph would rot on exactly the
3192    /// operation this release exists to make safe.
3193    #[test]
3194    fn re_indexing_the_target_file_leaves_edges_into_it_intact() {
3195        let db = Connection::open_in_memory().unwrap();
3196        let caller = one_symbol(&db, "a.rs", "caller");
3197        insert_edge(&db, caller, "callee", "calls", "inferred", None, None).unwrap();
3198        let b = insert_file(&db, "b.rs", "h", 1, 0).unwrap();
3199        insert_symbol(&db, b, None, &sym("callee")).unwrap();
3200        assert_eq!(edges_out(&db, "caller", &[]).unwrap().len(), 1);
3201
3202        // b.rs changes: its symbols are dropped and re-extracted with new ids.
3203        delete_file(&db, "b.rs", 0).unwrap();
3204        let b = insert_file(&db, "b.rs", "h2", 1, 0).unwrap();
3205        insert_symbol(&db, b, None, &at(sym("callee"), 9, 10)).unwrap();
3206
3207        let out = edges_out(&db, "caller", &[]).unwrap();
3208        assert_eq!(
3209            out.len(),
3210            1,
3211            "the edge survived its target being re-indexed"
3212        );
3213        assert_eq!(
3214            out[0].symbol.start_line, 9,
3215            "and now points at the new rows"
3216        );
3217    }
3218
3219    /// A store with one caller and several definitions of the name it calls.
3220    ///
3221    /// Returns the connection; `paths` are the files each definition of
3222    /// `callee` lives in, and the caller is always `src/caller.rs`.
3223    fn many_definitions(paths: &[&str]) -> Connection {
3224        let db = Connection::open_in_memory().unwrap();
3225        one_symbol(&db, "src/caller.rs", "caller");
3226        for (i, path) in paths.iter().enumerate() {
3227            // The caller's own file is already there, and a definition in it
3228            // is exactly the case the same-file rank is about.
3229            let file = db
3230                .query_row("SELECT id FROM files WHERE path = ?1", params![path], |r| {
3231                    r.get(0)
3232                })
3233                .optional()
3234                .unwrap()
3235                .unwrap_or_else(|| insert_file(&db, path, &format!("h{i}"), 1, 0).unwrap());
3236            insert_symbol(&db, file, None, &sym("callee")).unwrap();
3237        }
3238        db
3239    }
3240
3241    fn call(db: &Connection, hint: Option<&str>) {
3242        let src: i64 = db
3243            .query_row("SELECT id FROM symbols WHERE name = 'caller'", [], |r| {
3244                r.get(0)
3245            })
3246            .unwrap();
3247        insert_edge(db, src, "callee", "calls", "inferred", hint, None).unwrap();
3248    }
3249
3250    /// One definition of the name is one answer, whatever the hint says.
3251    #[test]
3252    fn a_name_with_one_definition_resolves() {
3253        let db = many_definitions(&["src/other.rs"]);
3254        call(&db, None);
3255        let out = edges_out(&db, "caller", &[]).unwrap();
3256        assert_eq!(out.len(), 1);
3257        assert_eq!(out[0].confidence, crate::graph::RESOLVED);
3258        assert_eq!(out[0].definitions, 1);
3259    }
3260
3261    /// Four definitions and nothing to choose between them. Every candidate
3262    /// comes back marked `ambiguous` and carrying the count, so a renderer can
3263    /// say "4 definitions" instead of printing four calls the source never
3264    /// made.
3265    #[test]
3266    fn a_name_nothing_narrows_is_ambiguous_and_keeps_every_candidate() {
3267        let db = many_definitions(&["src/a.rs", "src/b.rs", "src/c.rs", "src/d.rs"]);
3268        call(&db, None);
3269        let out = edges_out(&db, "caller", &[]).unwrap();
3270        assert_eq!(out.len(), 4);
3271        assert!(out.iter().all(|e| e.confidence == crate::graph::AMBIGUOUS));
3272        assert!(out.iter().all(|e| e.definitions == 4));
3273    }
3274
3275    /// The hint picks the definition whose file it names, and the other three
3276    /// are not returned at all.
3277    #[test]
3278    fn a_hint_that_names_a_file_resolves_to_that_definition() {
3279        let db = many_definitions(&["src/a.rs", "src/store.rs", "src/c.rs"]);
3280        call(&db, Some("store"));
3281        let out = edges_out(&db, "caller", &[]).unwrap();
3282        assert_eq!(out.len(), 1, "{out:?}");
3283        assert_eq!(out[0].symbol.path, "src/store.rs");
3284        assert_eq!(out[0].confidence, crate::graph::RESOLVED);
3285        assert_eq!(
3286            out[0].definitions, 3,
3287            "the count is what the ranking chose between, not what survived"
3288        );
3289    }
3290
3291    /// A hint naming a directory works the same way: a module is a file or a
3292    /// directory depending on how the author felt that day.
3293    #[test]
3294    fn a_hint_names_a_directory_as_readily_as_a_file() {
3295        let db = many_definitions(&["src/a.rs", "src/store/mod.rs"]);
3296        call(&db, Some("store"));
3297        let out = edges_out(&db, "caller", &[]).unwrap();
3298        assert_eq!(out.len(), 1);
3299        assert_eq!(out[0].symbol.path, "src/store/mod.rs");
3300    }
3301
3302    /// The calling file's own definition wins over one the hint names. A file
3303    /// that defines a name and calls it means its own.
3304    #[test]
3305    fn the_calling_file_outranks_the_hint() {
3306        let db = many_definitions(&["src/caller.rs", "src/store.rs"]);
3307        call(&db, Some("store"));
3308        let out = edges_out(&db, "caller", &[]).unwrap();
3309        assert_eq!(out.len(), 1);
3310        assert_eq!(out[0].symbol.path, "src/caller.rs");
3311    }
3312
3313    /// With no hint, what the calling file imports breaks the tie.
3314    #[test]
3315    fn an_import_resolves_what_the_hint_cannot() {
3316        let db = many_definitions(&["src/a.rs", "src/b.rs"]);
3317        let src: i64 = db
3318            .query_row("SELECT id FROM symbols WHERE name = 'caller'", [], |r| {
3319                r.get(0)
3320            })
3321            .unwrap();
3322        insert_edge(
3323            &db,
3324            src,
3325            "crate::b::callee",
3326            "imports",
3327            "extracted",
3328            None,
3329            None,
3330        )
3331        .unwrap();
3332        call(&db, None);
3333        let out = edges_out(&db, "caller", &["calls".to_string()]).unwrap();
3334        assert_eq!(out.len(), 1, "{out:?}");
3335        assert_eq!(out[0].symbol.path, "src/b.rs");
3336        assert_eq!(out[0].confidence, crate::graph::RESOLVED);
3337    }
3338
3339    /// The hint outranks the import list: an import is a set of possibilities,
3340    /// a hint is a statement about this call.
3341    #[test]
3342    fn the_hint_outranks_an_import() {
3343        let db = many_definitions(&["src/a.rs", "src/b.rs"]);
3344        let src: i64 = db
3345            .query_row("SELECT id FROM symbols WHERE name = 'caller'", [], |r| {
3346                r.get(0)
3347            })
3348            .unwrap();
3349        insert_edge(
3350            &db,
3351            src,
3352            "crate::b::callee",
3353            "imports",
3354            "extracted",
3355            None,
3356            None,
3357        )
3358        .unwrap();
3359        call(&db, Some("a"));
3360        let out = edges_out(&db, "caller", &["calls".to_string()]).unwrap();
3361        assert_eq!(out.len(), 1, "{out:?}");
3362        assert_eq!(out[0].symbol.path, "src/a.rs");
3363    }
3364
3365    /// An edge the syntax tree settled is never downgraded by a ranking.
3366    #[test]
3367    fn an_extracted_edge_stays_extracted() {
3368        let db = many_definitions(&["src/store.rs"]);
3369        let src: i64 = db
3370            .query_row("SELECT id FROM symbols WHERE name = 'caller'", [], |r| {
3371                r.get(0)
3372            })
3373            .unwrap();
3374        insert_edge(
3375            &db,
3376            src,
3377            "callee",
3378            "calls",
3379            "extracted",
3380            Some("store"),
3381            None,
3382        )
3383        .unwrap();
3384        let out = edges_out(&db, "caller", &[]).unwrap();
3385        assert_eq!(out[0].confidence, crate::graph::EXTRACTED);
3386    }
3387
3388    /// A store written by 0.14.0 has a NULL hint on every row. It must answer
3389    /// exactly as it did then: one definition is the answer, several are a
3390    /// question.
3391    #[test]
3392    fn a_null_hint_behaves_as_it_did_before_hints_existed() {
3393        let db = many_definitions(&["src/a.rs"]);
3394        call(&db, None);
3395        let one = edges_out(&db, "caller", &[]).unwrap();
3396        assert_eq!(one.len(), 1);
3397        assert_eq!(one[0].symbol.path, "src/a.rs");
3398
3399        let db = many_definitions(&["src/a.rs", "src/b.rs"]);
3400        call(&db, None);
3401        let two = edges_out(&db, "caller", &[]).unwrap();
3402        assert_eq!(
3403            two.len(),
3404            2,
3405            "both candidates are still reachable, now labelled rather than asserted"
3406        );
3407    }
3408
3409    /// Callers are unchanged: the row came from the edge's own `src` id, so
3410    /// there is nothing to resolve and the stored confidence stands.
3411    #[test]
3412    fn callers_keep_the_stored_confidence() {
3413        let db = many_definitions(&["src/a.rs", "src/b.rs"]);
3414        call(&db, None);
3415        let inbound = edges_in(&db, "callee", &[]).unwrap();
3416        assert_eq!(inbound.len(), 1);
3417        assert_eq!(inbound[0].confidence, crate::graph::INFERRED);
3418        assert_eq!(inbound[0].definitions, 1);
3419    }
3420
3421    /// Both directions resolve, and the edge kind filter applies to each.
3422    #[test]
3423    fn edges_resolve_in_both_directions_and_filter_by_kind() {
3424        let db = Connection::open_in_memory().unwrap();
3425        let caller = one_symbol(&db, "a.rs", "caller");
3426        let b = insert_file(&db, "b.rs", "h", 1, 0).unwrap();
3427        insert_symbol(&db, b, None, &sym("callee")).unwrap();
3428        insert_edge(&db, caller, "callee", "calls", "extracted", None, None).unwrap();
3429        insert_edge(&db, caller, "callee", "references", "inferred", None, None).unwrap();
3430
3431        assert_eq!(edges_out(&db, "caller", &[]).unwrap().len(), 2);
3432        let calls = edges_out(&db, "caller", &["calls".to_string()]).unwrap();
3433        assert_eq!(calls.len(), 1);
3434        assert_eq!(calls[0].confidence, "extracted");
3435
3436        let inbound = edges_in(&db, "callee", &["calls".to_string()]).unwrap();
3437        assert_eq!(inbound.len(), 1);
3438        assert_eq!(
3439            inbound[0].symbol.name, "caller",
3440            "edges_in reports the source"
3441        );
3442    }
3443
3444    /// An edge to something the corpus does not contain is still recorded, and
3445    /// simply resolves to nothing. A call into the standard library is the
3446    /// common case, and dropping it would lose the fact that the call is there.
3447    #[test]
3448    fn an_edge_to_an_unindexed_target_resolves_to_nothing_without_erroring() {
3449        let db = Connection::open_in_memory().unwrap();
3450        let caller = one_symbol(&db, "a.rs", "caller");
3451        insert_edge(&db, caller, "println", "calls", "inferred", None, None).unwrap();
3452        assert!(edges_out(&db, "caller", &[]).unwrap().is_empty());
3453        assert_eq!(graph_stats(&db).unwrap().1, 1, "but the edge row is there");
3454    }
3455
3456    /// Every store written before 0.6.0 lacks the key entirely. Reading that as
3457    /// anything but format 1 would refuse the whole installed base.
3458    #[test]
3459    fn a_store_without_the_key_is_the_format_we_understand() {
3460        let db = Connection::open_in_memory().unwrap();
3461        db.execute_batch(SCHEMA).unwrap();
3462        assert!(get_meta(&db, FORMAT_KEY).unwrap().is_none());
3463        check_format(&db).expect("a pre-0.6.0 store is readable");
3464        // And still has no key: opening a store must not rewrite it.
3465        assert!(get_meta(&db, FORMAT_KEY).unwrap().is_none());
3466    }
3467
3468    /// The point of writing the format down: a later semlith's store is refused
3469    /// with both numbers, instead of read as though its layout were this one.
3470    #[test]
3471    fn a_newer_format_is_refused_naming_both_numbers() {
3472        let db = Connection::open_in_memory().unwrap();
3473        db.execute_batch(SCHEMA).unwrap();
3474        let newer = FORMAT_VERSION + 1;
3475        set_meta(&db, FORMAT_KEY, &newer.to_string()).unwrap();
3476
3477        let refused = check_format(&db).expect_err("a newer format must not be read");
3478        let said = refused.to_string();
3479        assert!(said.contains(&newer.to_string()), "{said}");
3480        assert!(
3481            said.contains(&FORMAT_VERSION.to_string()),
3482            "the error must name what this binary understands: {said}"
3483        );
3484
3485        // The format this binary wrote is of course readable.
3486        set_meta(&db, FORMAT_KEY, &FORMAT_VERSION.to_string()).unwrap();
3487        check_format(&db).unwrap();
3488    }
3489
3490    fn seeded() -> Connection {
3491        let db = Connection::open_in_memory().unwrap();
3492        db.pragma_update(None, "foreign_keys", "ON").unwrap();
3493        db.execute_batch(SCHEMA).unwrap();
3494        let f = insert_file(&db, "/a/lib.rs", "h", 10, 0).unwrap();
3495        insert_chunk(&db, f, 0, 1, 5, "const EMBED_BATCH: usize = 32;").unwrap();
3496        insert_chunk(
3497            &db,
3498            f,
3499            1,
3500            6,
3501            9,
3502            "a paragraph about retry backoff and jitter",
3503        )
3504        .unwrap();
3505        db
3506    }
3507
3508    /// A corpus with the same word in four files: two under `src`, two not,
3509    /// two Rust, two Markdown. Enough to tell a union from an intersection.
3510    /// A store holds the paths its own platform produces, and `filter::anchor`
3511    /// builds patterns to match those — backslashes on Windows, forward
3512    /// slashes everywhere else. A fixture written with one separator therefore
3513    /// tests nothing on the other platform, so these are spelled in the
3514    /// separator the code under test is going to use.
3515    fn native(path: &str) -> String {
3516        path.replace('/', std::path::MAIN_SEPARATOR_STR)
3517    }
3518
3519    fn mixed() -> Connection {
3520        let db = Connection::open_in_memory().unwrap();
3521        db.pragma_update(None, "foreign_keys", "ON").unwrap();
3522        db.execute_batch(SCHEMA).unwrap();
3523        for path in [
3524            "/proj/src/lib.rs",
3525            "/proj/src/notes.md",
3526            "/proj/vendor/other.rs",
3527            "/proj/README.MD",
3528        ] {
3529            let f = insert_file(&db, &native(path), "h", 10, 0).unwrap();
3530            insert_chunk(&db, f, 0, 1, 5, "retry backoff and jitter").unwrap();
3531        }
3532        db
3533    }
3534
3535    /// `groups()` output for a filter, so the tests exercise the same patterns
3536    /// the CLI produces rather than hand-written globs that could drift from it.
3537    fn filter(paths: &[&str], exts: &[&str], langs: &[&str]) -> Vec<Vec<String>> {
3538        let own = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
3539        crate::filter::Filter::new(&own(paths), &own(exts), &own(langs))
3540            .unwrap()
3541            .groups()
3542            .to_vec()
3543    }
3544
3545    fn paths_of(db: &Connection, ids: &[u64]) -> Vec<String> {
3546        let mut out: Vec<String> = ids
3547            .iter()
3548            .map(|&i| chunk(db, i).unwrap().unwrap().path)
3549            .collect();
3550        out.sort();
3551        out
3552    }
3553
3554    #[test]
3555    fn keyword_search_finds_an_exact_identifier() {
3556        let db = seeded();
3557        let hits = keyword_search(&db, "EMBED_BATCH", 10, &[]).unwrap();
3558        assert_eq!(hits, vec![1], "expected the defining chunk, got {hits:?}");
3559    }
3560
3561    #[test]
3562    fn a_path_glob_selects_only_what_is_under_it() {
3563        let db = mixed();
3564        let ids = filtered_chunk_ids(&db, &filter(&["src/**"], &[], &[])).unwrap();
3565        assert_eq!(
3566            paths_of(&db, &ids),
3567            [native("/proj/src/lib.rs"), native("/proj/src/notes.md")]
3568        );
3569    }
3570
3571    /// An absolute pattern means exactly itself: it must not pick up the
3572    /// `*/` anchoring a relative one gets.
3573    #[test]
3574    fn an_absolute_glob_matches_only_what_it_literally_covers() {
3575        let db = mixed();
3576        let ids = filtered_chunk_ids(&db, &filter(&["/proj/vendor/*"], &[], &[])).unwrap();
3577        assert_eq!(paths_of(&db, &ids), [native("/proj/vendor/other.rs")]);
3578    }
3579
3580    #[test]
3581    fn extensions_union_and_a_language_resolves_to_the_same_set() {
3582        let db = mixed();
3583        let by_ext = filtered_chunk_ids(&db, &filter(&[], &["rs"], &[])).unwrap();
3584        let by_lang = filtered_chunk_ids(&db, &filter(&[], &[], &["rust"])).unwrap();
3585        assert_eq!(paths_of(&db, &by_ext), paths_of(&db, &by_lang));
3586        assert_eq!(
3587            paths_of(&db, &by_ext),
3588            [native("/proj/src/lib.rs"), native("/proj/vendor/other.rs")]
3589        );
3590
3591        let both = filtered_chunk_ids(&db, &filter(&[], &["rs", "md"], &[])).unwrap();
3592        assert_eq!(both.len(), 4, "repeating --ext must union, not intersect");
3593    }
3594
3595    /// `README.MD` is Markdown. Extension matching folds case, so a filter
3596    /// written the way anyone would write it does not miss it.
3597    #[test]
3598    fn extension_matching_is_case_insensitive() {
3599        let db = mixed();
3600        let ids = filtered_chunk_ids(&db, &filter(&[], &["md"], &[])).unwrap();
3601        assert_eq!(
3602            paths_of(&db, &ids),
3603            [native("/proj/README.MD"), native("/proj/src/notes.md")]
3604        );
3605    }
3606
3607    #[test]
3608    fn a_path_and_an_extension_intersect() {
3609        let db = mixed();
3610        let ids = filtered_chunk_ids(&db, &filter(&["src/**"], &["md"], &[])).unwrap();
3611        assert_eq!(
3612            paths_of(&db, &ids),
3613            [native("/proj/src/notes.md")],
3614            "the README is Markdown but is not under src"
3615        );
3616    }
3617
3618    /// The whole point of the release: the keyword half must see the same
3619    /// subset the vector half is given, or fusion ranks a chunk one side was
3620    /// never allowed to return.
3621    #[test]
3622    fn the_keyword_half_honours_the_same_filter() {
3623        let db = mixed();
3624        let unfiltered = keyword_search(&db, "retry backoff", 10, &[]).unwrap();
3625        assert_eq!(unfiltered.len(), 4);
3626
3627        let scoped =
3628            keyword_search(&db, "retry backoff", 10, &filter(&["src/**"], &[], &[])).unwrap();
3629        assert_eq!(
3630            paths_of(&db, &scoped),
3631            [native("/proj/src/lib.rs"), native("/proj/src/notes.md")]
3632        );
3633    }
3634
3635    #[test]
3636    fn a_filter_matching_nothing_selects_nothing() {
3637        let db = mixed();
3638        let groups = filter(&["nowhere/**"], &[], &[]);
3639        assert_eq!(matching_files(&db, &groups).unwrap(), 0);
3640        assert!(filtered_chunk_ids(&db, &groups).unwrap().is_empty());
3641        assert!(
3642            keyword_search(&db, "retry", 10, &groups)
3643                .unwrap()
3644                .is_empty()
3645        );
3646    }
3647
3648    /// An unfiltered search must cost exactly what it cost in 0.2.0. The
3649    /// statement is the whole of that promise: adding the join unconditionally
3650    /// would tax every query that uses no filter, and would not fail any other
3651    /// test in this file.
3652    #[test]
3653    fn an_unfiltered_search_issues_the_0_2_0_statement_verbatim() {
3654        assert_eq!(
3655            UNFILTERED_SQL,
3656            "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?"
3657        );
3658        // And an empty filter must take that branch, not the general one.
3659        assert_eq!(glob_predicate(&[]), ("1".to_string(), vec![]));
3660    }
3661
3662    #[test]
3663    fn no_filter_counts_every_file() {
3664        let db = mixed();
3665        assert_eq!(matching_files(&db, &[]).unwrap(), 4);
3666    }
3667
3668    /// An exclusion on its own means everything except, so it needs no
3669    /// inclusion beside it to be a filter.
3670    #[test]
3671    fn an_exclusion_alone_means_everything_except() {
3672        let db = mixed();
3673        let f = filter(&["!**/vendor/**"], &[], &[]);
3674        assert_eq!(matching_files(&db, &f).unwrap(), 3);
3675    }
3676
3677    /// The exclusion applies after the inclusions of its own kind, so the two
3678    /// intersect rather than the later one replacing the earlier.
3679    #[test]
3680    fn an_exclusion_narrows_the_inclusions_of_its_kind() {
3681        let db = mixed();
3682        let f = filter(&["proj/**", "!**/vendor/**"], &[], &[]);
3683        assert_eq!(matching_files(&db, &f).unwrap(), 3);
3684
3685        let f = filter(&["proj/src/**", "!**/notes.md"], &[], &[]);
3686        assert_eq!(matching_files(&db, &f).unwrap(), 1);
3687    }
3688
3689    /// The bind values are positional, so an exclusion written before an
3690    /// inclusion must still bind to the `?` the clause puts it against.
3691    #[test]
3692    fn an_exclusion_written_first_still_binds_to_its_own_placeholder() {
3693        let db = mixed();
3694        let written_first = filter(&["!**/vendor/**", "proj/**"], &[], &[]);
3695        let written_last = filter(&["proj/**", "!**/vendor/**"], &[], &[]);
3696        assert_eq!(
3697            matching_files(&db, &written_first).unwrap(),
3698            matching_files(&db, &written_last).unwrap(),
3699        );
3700        assert_eq!(matching_files(&db, &written_first).unwrap(), 3);
3701    }
3702
3703    /// Extensions negate the way paths do, and case-insensitively: a store
3704    /// holding `README.MD` loses it to `--ext '!md'`.
3705    #[test]
3706    fn an_excluded_extension_removes_every_spelling_of_it() {
3707        let db = mixed();
3708        let f = filter(&[], &["!md"], &[]);
3709        assert_eq!(matching_files(&db, &f).unwrap(), 2);
3710    }
3711
3712    /// A group holding only exclusions still selects from everything, and the
3713    /// two kinds still intersect across groups.
3714    #[test]
3715    fn exclusions_of_different_kinds_intersect() {
3716        let db = mixed();
3717        let f = filter(&["!**/vendor/**"], &["!md"], &[]);
3718        assert_eq!(matching_files(&db, &f).unwrap(), 1);
3719    }
3720
3721    #[test]
3722    fn fts_syntax_in_a_query_is_not_executed_as_syntax() {
3723        let db = seeded();
3724        // Each of these is either an FTS5 syntax error or an FTS5 operator if
3725        // passed through raw. All must come back as ordinary searches.
3726        for query in [
3727            "EMBED_BATCH AND retry",
3728            "unbalanced \" quote",
3729            "call_me(",
3730            "prefix*",
3731            "NEAR(a b)",
3732            "-negated",
3733        ] {
3734            let result = keyword_search(&db, query, 10, &[]);
3735            assert!(
3736                result.is_ok(),
3737                "query {query:?} errored: {:?}",
3738                result.err()
3739            );
3740        }
3741    }
3742
3743    #[test]
3744    fn a_query_with_no_terms_matches_nothing() {
3745        let db = seeded();
3746        assert!(keyword_search(&db, "!!! ???", 10, &[]).unwrap().is_empty());
3747    }
3748
3749    #[test]
3750    fn deleting_a_file_removes_its_chunks_from_the_keyword_index() {
3751        let db = seeded();
3752        assert!(
3753            !keyword_search(&db, "EMBED_BATCH", 10, &[])
3754                .unwrap()
3755                .is_empty()
3756        );
3757        delete_file(&db, "/a/lib.rs", 0).unwrap();
3758        assert!(
3759            keyword_search(&db, "EMBED_BATCH", 10, &[])
3760                .unwrap()
3761                .is_empty(),
3762            "cascade delete left the keyword index stale"
3763        );
3764    }
3765
3766    /// A store written by 0.15.0 has no `edges.line`. Opening it must add the
3767    /// column without touching the rows, and every edge already in it must
3768    /// report no call site rather than a wrong one.
3769    #[test]
3770    fn a_pre_0_16_store_opens_and_its_edges_report_no_call_site() {
3771        let db = Connection::open_in_memory().unwrap();
3772        db.execute_batch(
3773            "CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE,
3774                 hash TEXT NOT NULL, bytes INTEGER NOT NULL, indexed_at INTEGER NOT NULL);
3775             CREATE TABLE symbols (id INTEGER PRIMARY KEY AUTOINCREMENT,
3776                 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
3777                 chunk_id INTEGER, kind TEXT NOT NULL, name TEXT NOT NULL,
3778                 qualified TEXT NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL);
3779             CREATE TABLE edges (src INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
3780                 dst TEXT NOT NULL, kind TEXT NOT NULL, confidence TEXT NOT NULL, hint TEXT);
3781             INSERT INTO files VALUES (1, '/a/lib.rs', 'h', 10, 0);
3782             INSERT INTO symbols VALUES (1, 1, NULL, 'function', 'caller', 'm::caller', 1, 9);
3783             INSERT INTO symbols VALUES (2, 1, NULL, 'function', 'callee', 'm::callee', 20, 24);
3784             CREATE TABLE retrievals (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL,
3785                 client TEXT NOT NULL, query TEXT NOT NULL, hits INTEGER NOT NULL,
3786                 micros INTEGER NOT NULL, excerpt_tokens INTEGER NOT NULL,
3787                 whole_file_tokens INTEGER NOT NULL);
3788             INSERT INTO edges VALUES (1, 'callee', 'calls', 'inferred', NULL);",
3789        )
3790        .unwrap();
3791
3792        add_columns(&db).unwrap();
3793
3794        let out = edges_out(&db, "caller", &[]).unwrap();
3795        assert_eq!(out.len(), 1, "{out:?}");
3796        assert_eq!(
3797            out[0].line, None,
3798            "a 0.15.0 row must not invent a call site"
3799        );
3800        assert_eq!(
3801            crate::graph::call_site(&out[0], &crate::graph::verbatim),
3802            "",
3803            "nothing is rendered for an edge with no line"
3804        );
3805
3806        let into = edges_in(&db, "callee", &[]).unwrap();
3807        assert_eq!(into.len(), 1, "{into:?}");
3808        assert_eq!(into[0].line, None);
3809    }
3810
3811    #[test]
3812    fn an_old_store_gets_its_keyword_index_backfilled() {
3813        // A 0.1.0 store: chunks written with no FTS table in the schema.
3814        let db = Connection::open_in_memory().unwrap();
3815        db.execute_batch(
3816            "CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
3817             CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE,
3818                 hash TEXT NOT NULL, bytes INTEGER NOT NULL, indexed_at INTEGER NOT NULL);
3819             CREATE TABLE chunks (id INTEGER PRIMARY KEY, file_id INTEGER NOT NULL
3820                 REFERENCES files(id) ON DELETE CASCADE, ord INTEGER NOT NULL,
3821                 start_line INTEGER NOT NULL, end_line INTEGER NOT NULL, text TEXT NOT NULL);
3822             INSERT INTO files VALUES (1, '/a/lib.rs', 'h', 10, 0);
3823             INSERT INTO chunks VALUES (1, 1, 0, 1, 5, 'const EMBED_BATCH: usize = 32;');",
3824        )
3825        .unwrap();
3826
3827        // Opening it with the current schema must index what is already there.
3828        db.execute_batch(SCHEMA).unwrap();
3829        backfill_fts(&db).unwrap();
3830        assert_eq!(
3831            keyword_search(&db, "EMBED_BATCH", 10, &[]).unwrap(),
3832            vec![1]
3833        );
3834    }
3835}