1use 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#[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 read_only(&db, true)?;
231 Ok(db)
232}
233
234fn 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
257pub fn read_only(db: &Connection, on: bool) -> Result<()> {
267 db.pragma_update(None, "query_only", if on { "ON" } else { "OFF" })?;
268 Ok(())
269}
270
271pub 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 let _ = read_only(self.0, true);
292 }
293}
294
295pub const FORMAT_VERSION: u32 = 4;
309
310pub const SHARDED_FORMAT: u32 = 2;
312
313pub const CODE_CONTEXT: u32 = 4;
322
323pub const DEFINITION_CHUNKS: u32 = 3;
333
334pub 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
346pub 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
358fn 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
381const FTS_BUILT: &str = "fts_built";
388
389fn add_columns(db: &Connection) -> Result<()> {
409 const ADDITIONS: [(&str, &str, &str); 8] = [
410 ("edges", "hint", "TEXT"),
411 ("edges", "line", "INTEGER"),
413 ("retrievals", "session", "TEXT"),
419 ("retrievals", "tool", "TEXT"),
420 ("retrievals", "stale_hits", "INTEGER"),
421 ("retrievals", "tokenizer", "TEXT"),
422 ("retrievals", "query_id", "TEXT"),
430 ("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 db.execute_batch("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');")?;
465 set_meta(db, FTS_BUILT, "1")?;
466 Ok(())
467}
468
469fn 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 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.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 (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
512pub 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
532pub 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
544const UNFILTERED_SQL: &str =
551 "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY rank LIMIT ?";
552
553pub 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 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
621pub 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
632pub 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
650fn 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
671pub 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
699pub 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#[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 pub content_hash: String,
716 pub retired_at: i64,
717 #[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
730pub 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#[derive(Debug, Clone, Default, serde::Serialize)]
747pub struct LanguageCoverage {
748 pub language: String,
749 pub files: usize,
751 pub parser_failed: usize,
754 pub definitions: usize,
755 pub extracted: usize,
757 pub resolved: usize,
759 pub ambiguous: usize,
761 pub unresolved: usize,
764}
765
766impl LanguageCoverage {
767 #[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
779pub 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 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
880pub 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#[cfg(windows)]
910const PATH_AS_TYPED: &str = r#"replace(path, '\', '/')"#;
911#[cfg(not(windows))]
912const PATH_AS_TYPED: &str = "path";
913
914#[cfg(windows)]
916const GLOB_PATH: &str = r#"replace(lower(f.path), '\', '/')"#;
917#[cfg(not(windows))]
918const GLOB_PATH: &str = "lower(f.path)";
919
920pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
990pub enum FileSort {
991 Path,
992 Bytes,
993 Chunks,
994 Lines,
995 Indexed,
996 Store,
999 Reader,
1001 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 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 Self::Store | Self::Reader | Self::Lang => "f.path",
1040 }
1041 }
1042}
1043
1044pub 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
1057pub 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#[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 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#[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
1162pub 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
1181pub 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
1193pub fn image_count(db: &Connection) -> Result<i64> {
1195 Ok(db.query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0))?)
1196}
1197
1198pub 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
1205pub 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
1220pub 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
1240pub 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
1254pub 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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
1292 pub store: Option<String>,
1293}
1294
1295#[derive(Debug, Clone, serde::Serialize)]
1297pub struct EdgeEnd {
1298 #[serde(flatten)]
1299 pub symbol: SymbolRow,
1300 pub kind: String,
1301 pub confidence: String,
1302 pub definitions: usize,
1311 #[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 #[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
1391pub 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
1406pub 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 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
1441pub 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
1468pub fn edges_out(db: &Connection, name: &str, kinds: &[String]) -> Result<Vec<EdgeEnd>> {
1503 let filter = kind_predicate(kinds, "e.kind");
1504 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
1535struct 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
1547fn resolve(db: &Connection, reached: Vec<Reached>) -> Result<Vec<EdgeEnd>> {
1555 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 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 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 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
1654const UNRANKED: u8 = 3;
1657
1658fn 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
1666fn 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 UNRANKED
1698}
1699
1700fn 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
1717fn 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
1729pub 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#[derive(Debug, Clone, serde::Serialize)]
1763pub struct Unresolved {
1764 pub name: String,
1765 pub kind: String,
1766 pub confidence: String,
1767}
1768
1769pub 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 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
1811pub 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 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#[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 pub query_id: String,
1850}
1851
1852pub 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 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 crate::daemon::changes::bump(crate::daemon::changes::Domain::Ledger);
1902 Ok(())
1903}
1904
1905#[derive(Debug, Clone)]
1910pub struct NewRetrieval<'a> {
1911 pub client: &'a str,
1913 pub session: &'a str,
1915 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 pub stale_hits: i64,
1924 pub tokenizer: &'a str,
1928 pub query_id: &'a str,
1935}
1936
1937fn 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 blake3::hash(payload.as_bytes()).to_hex().to_string()
1981}
1982
1983pub 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 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#[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
2061pub 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#[derive(Copy, Clone, Debug, PartialEq, Eq)]
2087pub enum LedgerScope {
2088 All,
2090 Credited,
2093 Estimated,
2096}
2097
2098pub 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
2128pub fn ledger_totals(db: &Connection) -> Result<(i64, i64, i64, i64)> {
2131 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
2145pub fn ledger_savings(db: &Connection) -> Result<Savings> {
2167 ledger_savings_since(db, 0)
2168}
2169
2170pub fn ledger_savings_since(db: &Connection, since: i64) -> Result<Savings> {
2179 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#[derive(Debug, Clone, Copy, serde::Serialize)]
2212pub struct Savings {
2213 pub net: i64,
2215 pub credited: i64,
2217 pub total: i64,
2220 pub measured: bool,
2222}
2223
2224impl Savings {
2225 pub fn tier(&self) -> &'static str {
2227 if self.measured {
2228 "measured"
2229 } else {
2230 "modelled"
2231 }
2232 }
2233
2234 pub fn coverage(&self) -> i64 {
2236 if self.total == 0 {
2237 return 0;
2238 }
2239 self.credited * 100 / self.total
2240 }
2241}
2242
2243pub 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#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
2275pub struct Misses {
2276 pub refunds: i64,
2278 pub zero_hit: i64,
2280 pub measured: bool,
2282}
2283
2284pub 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
2293pub 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 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 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
2352pub 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
2371pub fn holds_path(db: &Connection, path: &str) -> Result<bool> {
2378 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
2393pub type SymbolSpan = (u32, u32, String, String);
2395
2396pub 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
2431pub 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
2452pub 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
2464pub 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
2471pub 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 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#[derive(Debug, Clone, Default, serde::Serialize)]
2522pub struct Corpus {
2523 pub files: i64,
2524 pub chunks: i64,
2525 pub bytes: i64,
2526 pub lines: i64,
2529 pub words: i64,
2530 pub characters: i64,
2531 pub blank_lines: i64,
2532 pub comment_lines: i64,
2536 pub languages: Vec<LanguageLines>,
2538 pub kinds: Vec<KindCount>,
2540 pub longest_file: Option<FileLines>,
2541 pub deepest_path: i64,
2543 pub first_indexed: i64,
2545 pub last_indexed: i64,
2546 pub months: Vec<MonthCount>,
2548 pub vector_dim: i64,
2550 pub symbols: i64,
2551 pub edges: i64,
2552 pub tiers: Vec<KindCount>,
2555 pub unresolved: i64,
2558 pub unresolved_names: Vec<KindCount>,
2559 pub ambiguous_names: i64,
2561 pub ambiguous_worst: Vec<KindCount>,
2562 pub languages_with_edges: i64,
2564 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 pub month: String,
2594 pub chunks: i64,
2595}
2596
2597fn comment_openers(language: &str) -> &'static [&'static str] {
2604 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
2621pub 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 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 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 out.median_query_ms = db
2791 .query_row(
2792 "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
2805fn 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#[derive(Debug, Clone, Default)]
2821pub struct SessionRow {
2822 pub session: String,
2825 pub client: String,
2826 pub first: i64,
2828 pub last: i64,
2829 pub retrievals: i64,
2830 pub zero_hit: i64,
2833 pub excerpt_tokens: i64,
2834 pub whole_file_tokens: i64,
2835 pub net: i64,
2837 pub measured: i64,
2840}
2841
2842impl SessionRow {
2843 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
2855pub 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
2890pub 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
2905pub 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
2917pub fn ledger_zero_hit_queries(db: &Connection, limit: usize) -> Result<Vec<(String, i64)>> {
2923 ledger_zero_hit_queries_since(db, 0, limit)
2924}
2925
2926pub 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#[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
2954pub 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
2983pub 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
2997pub struct NamedEdge {
3004 pub from: String,
3005 pub to: String,
3006}
3007
3008pub fn community_edges(db: &Connection, limit: usize) -> Result<(Vec<NamedEdge>, i64)> {
3017 let target = crate::graph::not_navigational("s.kind");
3018 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
3050pub 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
3070pub 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
3103pub 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
3121pub 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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(get_meta(&db, FORMAT_KEY).unwrap().is_none());
3466 }
3467
3468 #[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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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}