Skip to main content

devscout_rs/
store.rs

1// sqlite: read-cache, bash-cache, content dedup. rusqlite `bundled`, WAL +
2// busy_timeout on every connection, short explicit transactions, safe under a
3// concurrently writing hook process.
4//
5// SQL text (including the three CREATE TABLE schema strings) is kept stable and
6// explicit so a database's on-disk schema is fully determined by this file.
7//
8// Busy handling: `with_busy_retry` is a second, independent retry layer above
9// the busy_timeout pragma, for the window sustained multi-writer contention can
10// still miss (a hook process and this process writing the same live cache.db /
11// content.db). Bounded attempts, small linear backoff, retries only
12// SQLITE_BUSY/SQLITE_LOCKED, rethrows the last error unchanged on exhaustion,
13// any other error rethrown immediately. The `prune()` DELETE and the one-time
14// `add_agent_scope` migration are deliberately NOT wrapped.
15
16use std::env;
17use std::path::{Path, PathBuf};
18use std::thread;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use rusqlite::{params, Connection, ErrorCode, OptionalExtension};
22
23use crate::repo;
24
25// ---------------------------------------------------------------------------
26// Schema strings -- exact text (including whitespace) matters. SQLite stores
27// this verbatim as `sqlite_master.sql`'s text for a `CREATE TABLE <name>
28// <schema>` statement (SQLite drops `IF NOT EXISTS` from the stored text but
29// keeps everything else character-for-character), so the stored schema is fully
30// determined by these constants.
31// ---------------------------------------------------------------------------
32
33const READS_SCHEMA: &str = "(
34      session_id   TEXT NOT NULL,
35      agent_id     TEXT NOT NULL DEFAULT '',
36      rel_path     TEXT NOT NULL,
37      sha256       TEXT NOT NULL,
38      size         INTEGER NOT NULL,
39      mtime        INTEGER NOT NULL,
40      first_seen_ts INTEGER NOT NULL,
41      lines        INTEGER NOT NULL,
42      stub_count   INTEGER NOT NULL DEFAULT 0,
43      last_read_ts INTEGER,
44      read_count      INTEGER NOT NULL DEFAULT 0,
45      bytes_delivered INTEGER NOT NULL DEFAULT 0,
46      PRIMARY KEY (session_id, agent_id, rel_path)
47    )";
48
49const BASH_SCHEMA: &str = "(
50      session_id   TEXT NOT NULL,
51      agent_id     TEXT NOT NULL DEFAULT '',
52      cache_key    TEXT NOT NULL,
53      sha256       TEXT NOT NULL,
54      size         INTEGER NOT NULL,
55      lines        INTEGER NOT NULL,
56      stub_count   INTEGER NOT NULL DEFAULT 0,
57      first_seen_ts INTEGER NOT NULL,
58      last_read_ts INTEGER,
59      PRIMARY KEY (session_id, agent_id, cache_key)
60    )";
61
62const CONTENT_SCHEMA: &str = "(
63      session_id    TEXT NOT NULL,
64      agent_id      TEXT NOT NULL DEFAULT '',
65      sha256        TEXT NOT NULL,
66      root          TEXT NOT NULL,
67      rel_path      TEXT NOT NULL,
68      size          INTEGER NOT NULL,
69      lines         INTEGER NOT NULL,
70      stub_count    INTEGER NOT NULL DEFAULT 0,
71      first_seen_ts INTEGER NOT NULL,
72      last_read_ts  INTEGER,
73      PRIMARY KEY (session_id, agent_id, sha256)
74    )";
75
76const READS_COLS: [&str; 12] = [
77    "session_id",
78    "agent_id",
79    "rel_path",
80    "sha256",
81    "size",
82    "mtime",
83    "first_seen_ts",
84    "lines",
85    "stub_count",
86    "last_read_ts",
87    "read_count",
88    "bytes_delivered",
89];
90
91const BASH_COLS: [&str; 9] = [
92    "session_id",
93    "agent_id",
94    "cache_key",
95    "sha256",
96    "size",
97    "lines",
98    "stub_count",
99    "first_seen_ts",
100    "last_read_ts",
101];
102
103const CONTENT_COLS: [&str; 10] = [
104    "session_id",
105    "agent_id",
106    "sha256",
107    "root",
108    "rel_path",
109    "size",
110    "lines",
111    "stub_count",
112    "first_seen_ts",
113    "last_read_ts",
114];
115
116// ---------------------------------------------------------------------------
117// Busy retry
118// ---------------------------------------------------------------------------
119
120// True only for sqlite's busy/locked condition, surfaced by rusqlite as
121// `ErrorCode::DatabaseBusy` / `ErrorCode::DatabaseLocked` on a `SqliteFailure`.
122fn is_busy_error(err: &rusqlite::Error) -> bool {
123    matches!(
124        err,
125        rusqlite::Error::SqliteFailure(ffi_err, _)
126            if matches!(ffi_err.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
127    )
128}
129
130/// Runs `f`, retrying only a busy/locked error: bounded `attempts`, linear
131/// backoff (`backoff_ms * (attempt + 1)`, no sleep after the last attempt). Any
132/// other error propagates immediately; exhausting attempts propagates the LAST
133/// busy error unchanged (not a new "gave up" error), so a caller that treats a
134/// store error as "skip caching" always sees the underlying error shape.
135pub fn with_busy_retry_opts<T, F>(attempts: u32, backoff_ms: u64, f: F) -> rusqlite::Result<T>
136where
137    F: Fn() -> rusqlite::Result<T>,
138{
139    let mut last_err: Option<rusqlite::Error> = None;
140    for attempt in 0..attempts {
141        match f() {
142            Ok(v) => return Ok(v),
143            Err(e) => {
144                if !is_busy_error(&e) {
145                    return Err(e);
146                }
147                if attempt < attempts - 1 {
148                    thread::sleep(Duration::from_millis(backoff_ms * u64::from(attempt + 1)));
149                }
150                last_err = Some(e);
151            }
152        }
153    }
154    Err(last_err.expect("attempts > 0 guarantees at least one iteration"))
155}
156
157/// `with_busy_retry_opts` with the default policy (5 attempts, 10ms backoff).
158/// Used by every write path below.
159pub fn with_busy_retry<T, F>(f: F) -> rusqlite::Result<T>
160where
161    F: Fn() -> rusqlite::Result<T>,
162{
163    with_busy_retry_opts(5, 10, f)
164}
165
166// ---------------------------------------------------------------------------
167// Shared helpers
168// ---------------------------------------------------------------------------
169
170// Milliseconds since the Unix epoch.
171fn now_ms() -> i64 {
172    SystemTime::now()
173        .duration_since(UNIX_EPOCH)
174        .map(|d| d.as_millis() as i64)
175        .unwrap_or(0)
176}
177
178// `PRAGMA table_info(table)`'s column names, in table-definition order. Column
179// 1 of `table_info`'s row shape is `name` (0=cid, 1=name, 2=type, 3=notnull,
180// 4=dflt_value, 5=pk).
181fn columns(conn: &Connection, table: &str) -> rusqlite::Result<Vec<String>> {
182    let sql = format!("PRAGMA table_info({table})");
183    let mut stmt = conn.prepare(&sql)?;
184    let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
185    rows.collect()
186}
187
188// One-time rebuild of a pre-agent-scope table (SQLite cannot ALTER a primary
189// key). No-op once `agent_id` is already a column, which is always true for a
190// table created fresh via the `*_SCHEMA` constants above -- this only fires on a
191// pre-migration fixture. Existing rows predate agent scoping and therefore
192// belong to the main thread (`agent_id ''`). Deliberately NOT wrapped in
193// `with_busy_retry`: a one-time migration, not the hot write path.
194fn add_agent_scope(
195    conn: &Connection,
196    table: &str,
197    schema: &str,
198    cols: &[&str],
199) -> rusqlite::Result<()> {
200    if columns(conn, table)?.iter().any(|c| c == "agent_id") {
201        return Ok(());
202    }
203    let tmp = format!("{table}_agent_scoped");
204    let target = cols.join(", ");
205    let source = cols
206        .iter()
207        .map(|c| {
208            if *c == "agent_id" {
209                "''".to_string()
210            } else {
211                (*c).to_string()
212            }
213        })
214        .collect::<Vec<_>>()
215        .join(", ");
216    let sql = format!(
217        "BEGIN;\nCREATE TABLE {tmp} {schema};\nINSERT INTO {tmp} ({target}) SELECT {source} FROM {table};\nDROP TABLE {table};\nALTER TABLE {tmp} RENAME TO {table};\nCOMMIT;"
218    );
219    conn.execute_batch(&sql)
220}
221
222// ---------------------------------------------------------------------------
223// cache.db -- reads + bash_reads
224// ---------------------------------------------------------------------------
225
226/// Opens (creating on first use) `<root>/.scout/cache.db`, applies the WAL +
227/// busy_timeout=5000 pragmas (load-bearing for concurrent writers), creates
228/// `reads`/`bash_reads` if absent, and runs every standing migration
229/// (last_read_ts backfill, read_count/bytes_delivered backfill, agent-scope
230/// rebuild) idempotently. A freshly created database never takes any migration
231/// branch -- the `CREATE TABLE` already has every column the migrations would
232/// otherwise add.
233pub fn open_store(root: &Path) -> rusqlite::Result<Connection> {
234    let db_path = repo::scout_dir(root).join("cache.db");
235    let conn = Connection::open(db_path)?;
236    conn.execute_batch("PRAGMA journal_mode = WAL")?;
237    conn.execute_batch("PRAGMA busy_timeout = 5000")?;
238    conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS reads {READS_SCHEMA};"))?;
239    conn.execute_batch(&format!(
240        "CREATE TABLE IF NOT EXISTS bash_reads {BASH_SCHEMA};"
241    ))?;
242    for table in ["reads", "bash_reads"] {
243        if !columns(&conn, table)?.iter().any(|c| c == "last_read_ts") {
244            conn.execute_batch(&format!(
245                "ALTER TABLE {table} ADD COLUMN last_read_ts INTEGER"
246            ))?;
247        }
248    }
249    if !columns(&conn, "reads")?.iter().any(|c| c == "read_count") {
250        conn.execute_batch("ALTER TABLE reads ADD COLUMN read_count INTEGER NOT NULL DEFAULT 0")?;
251        conn.execute_batch(
252            "ALTER TABLE reads ADD COLUMN bytes_delivered INTEGER NOT NULL DEFAULT 0",
253        )?;
254        conn.execute_batch(
255            "UPDATE reads SET read_count = 1, bytes_delivered = size WHERE sha256 <> ''",
256        )?;
257    }
258    add_agent_scope(&conn, "reads", READS_SCHEMA, &READS_COLS)?;
259    add_agent_scope(&conn, "bash_reads", BASH_SCHEMA, &BASH_COLS)?;
260    Ok(conn)
261}
262
263/// Row shape shared by `lookup_read` and `lookup_bash`: the
264/// `SELECT sha256, lines, stub_count FROM ... WHERE ...` result.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct StubRow {
267    /// The sha256 value.
268    pub sha256: String,
269    /// The lines value.
270    pub lines: i64,
271    /// The stub count value.
272    pub stub_count: i64,
273}
274
275/// The cached stub row for a bash command, or `None` if uncached.
276pub fn lookup_bash(
277    conn: &Connection,
278    session_id: &str,
279    cache_key: &str,
280    agent_id: &str,
281) -> rusqlite::Result<Option<StubRow>> {
282    conn.query_row(
283        "SELECT sha256, lines, stub_count FROM bash_reads WHERE session_id = ? AND agent_id = ? AND cache_key = ?",
284        params![session_id, agent_id, cache_key],
285        |row| Ok(StubRow { sha256: row.get(0)?, lines: row.get(1)?, stub_count: row.get(2)? }),
286    )
287    .optional()
288}
289
290/// Arguments for `record_bash_fresh`: a freshly cached bash command result.
291pub struct RecordBashFresh<'a> {
292    /// The session id value.
293    pub session_id: &'a str,
294    /// The agent id value.
295    pub agent_id: &'a str,
296    /// The cache key value.
297    pub cache_key: &'a str,
298    /// The sha256 value.
299    pub sha256: &'a str,
300    /// The size value.
301    pub size: i64,
302    /// The lines value.
303    pub lines: i64,
304}
305
306/// Inserts or replaces the fresh shell-command row described by `p`.
307pub fn record_bash_fresh(conn: &Connection, p: &RecordBashFresh) -> rusqlite::Result<()> {
308    let now = now_ms();
309    with_busy_retry(|| {
310        conn.execute(
311            "INSERT INTO bash_reads (session_id, agent_id, cache_key, sha256, size, lines, stub_count, first_seen_ts, last_read_ts)
312             VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
313             ON CONFLICT(session_id, agent_id, cache_key) DO UPDATE SET
314               sha256 = excluded.sha256, size = excluded.size, lines = excluded.lines,
315               stub_count = 0, last_read_ts = excluded.last_read_ts",
316            params![p.session_id, p.agent_id, p.cache_key, p.sha256, p.size, p.lines, now, now],
317        )?;
318        Ok(())
319    })
320}
321
322/// Records another stubbed (cache-hit) read of a bash command.
323pub fn bump_bash_stub(
324    conn: &Connection,
325    session_id: &str,
326    cache_key: &str,
327    agent_id: &str,
328) -> rusqlite::Result<()> {
329    let now = now_ms();
330    with_busy_retry(|| {
331        conn.execute(
332            "UPDATE bash_reads SET stub_count = stub_count + 1, last_read_ts = ? WHERE session_id = ? AND agent_id = ? AND cache_key = ?",
333            params![now, session_id, agent_id, cache_key],
334        )?;
335        Ok(())
336    })
337}
338
339/// Aggregate bash-cache statistics (`bash_stats_for`).
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct BashStats {
342    /// The commands tracked value.
343    pub commands_tracked: i64,
344    /// The total stubs value.
345    pub total_stubs: i64,
346    /// The bytes saved value.
347    pub bytes_saved: i64,
348}
349
350/// Returns aggregate shell-command freshness statistics.
351pub fn bash_stats_for(conn: &Connection) -> rusqlite::Result<BashStats> {
352    conn.query_row(
353        "SELECT COUNT(*) AS n, COALESCE(SUM(stub_count),0) AS stubs, COALESCE(SUM(size*stub_count),0) AS bytes_saved FROM bash_reads",
354        [],
355        |row| {
356            Ok(BashStats {
357                commands_tracked: row.get(0)?,
358                total_stubs: row.get(1)?,
359                bytes_saved: row.get(2)?,
360            })
361        },
362    )
363}
364
365/// The cached stub row for a file read, or `None` if uncached.
366pub fn lookup_read(
367    conn: &Connection,
368    session_id: &str,
369    rel_path: &str,
370    agent_id: &str,
371) -> rusqlite::Result<Option<StubRow>> {
372    conn.query_row(
373        "SELECT sha256, lines, stub_count FROM reads WHERE session_id = ? AND agent_id = ? AND rel_path = ?",
374        params![session_id, agent_id, rel_path],
375        |row| Ok(StubRow { sha256: row.get(0)?, lines: row.get(1)?, stub_count: row.get(2)? }),
376    )
377    .optional()
378}
379
380/// Arguments for `record_fresh`: a freshly cached file read. `delivered = false`
381/// is the cross-repo content-stub caller: bytes never reached the model, so
382/// `bytes_delivered` must not move even though the row is written.
383pub struct RecordFresh<'a> {
384    /// The session id value.
385    pub session_id: &'a str,
386    /// The agent id value.
387    pub agent_id: &'a str,
388    /// The rel path value.
389    pub rel_path: &'a str,
390    /// The sha256 value.
391    pub sha256: &'a str,
392    /// The size value.
393    pub size: i64,
394    /// The mtime value.
395    pub mtime: i64,
396    /// The lines value.
397    pub lines: i64,
398    /// The delivered value.
399    pub delivered: bool,
400}
401
402/// Inserts or replaces the fresh file-read row described by `p`.
403pub fn record_fresh(conn: &Connection, p: &RecordFresh) -> rusqlite::Result<()> {
404    let now = now_ms();
405    let bytes_delivered = if p.delivered { p.size } else { 0 };
406    with_busy_retry(|| {
407        conn.execute(
408            "INSERT INTO reads (session_id, agent_id, rel_path, sha256, size, mtime, first_seen_ts, lines, stub_count, last_read_ts, read_count, bytes_delivered)
409             VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?)
410             ON CONFLICT(session_id, agent_id, rel_path) DO UPDATE SET
411               sha256 = excluded.sha256, size = excluded.size, mtime = excluded.mtime,
412               lines = excluded.lines, stub_count = 0, last_read_ts = excluded.last_read_ts,
413               read_count = reads.read_count + 1,
414               bytes_delivered = reads.bytes_delivered + excluded.bytes_delivered",
415            params![p.session_id, p.agent_id, p.rel_path, p.sha256, p.size, p.mtime, now, p.lines, now, bytes_delivered],
416        )?;
417        Ok(())
418    })
419}
420
421/// Arguments for `record_spend`: a partial read where the model paid for the
422/// slice but nothing about it may be cached, so the row (on first insert)
423/// carries `sha256 ''` -- a value no real digest can equal, so `lookup_read`'s
424/// equality check can never treat it as a stub candidate. On conflict, only the
425/// spend columns move.
426pub struct RecordSpend<'a> {
427    /// The session id value.
428    pub session_id: &'a str,
429    /// The agent id value.
430    pub agent_id: &'a str,
431    /// The rel path value.
432    pub rel_path: &'a str,
433    /// The size value.
434    pub size: i64,
435}
436
437/// Adds a delivered read to an existing file-read row.
438pub fn record_spend(conn: &Connection, p: &RecordSpend) -> rusqlite::Result<()> {
439    let now = now_ms();
440    with_busy_retry(|| {
441        conn.execute(
442            "INSERT INTO reads (session_id, agent_id, rel_path, sha256, size, mtime, first_seen_ts, lines, stub_count, last_read_ts, read_count, bytes_delivered)
443             VALUES (?, ?, ?, '', 0, 0, ?, 0, 0, ?, 1, ?)
444             ON CONFLICT(session_id, agent_id, rel_path) DO UPDATE SET
445               last_read_ts = excluded.last_read_ts,
446               read_count = reads.read_count + 1,
447               bytes_delivered = reads.bytes_delivered + excluded.bytes_delivered",
448            params![p.session_id, p.agent_id, p.rel_path, now, now, p.size],
449        )?;
450        Ok(())
451    })
452}
453
454/// Records another stubbed (cache-hit) read of a file.
455pub fn bump_stub(
456    conn: &Connection,
457    session_id: &str,
458    rel_path: &str,
459    agent_id: &str,
460) -> rusqlite::Result<()> {
461    let now = now_ms();
462    with_busy_retry(|| {
463        conn.execute(
464            "UPDATE reads SET stub_count = stub_count + 1, read_count = read_count + 1, last_read_ts = ? WHERE session_id = ? AND agent_id = ? AND rel_path = ?",
465            params![now, session_id, agent_id, rel_path],
466        )?;
467        Ok(())
468    })
469}
470
471/// One row of the per-(session, agent, path) breakdown `scout report` shapes.
472/// Rows with no reads at all (`read_count = 0` AND `stub_count = 0`) are
473/// excluded.
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct PathBreakdownRow {
476    /// The session id value.
477    pub session_id: String,
478    /// The agent id value.
479    pub agent_id: String,
480    /// The rel path value.
481    pub rel_path: String,
482    /// The read count value.
483    pub read_count: i64,
484    /// The bytes delivered value.
485    pub bytes_delivered: i64,
486    /// The size value.
487    pub size: i64,
488    /// The lines value.
489    pub lines: i64,
490    /// The stub count value.
491    pub stub_count: i64,
492    /// The first seen ts value.
493    pub first_seen_ts: i64,
494    /// The last read ts value.
495    pub last_read_ts: Option<i64>,
496}
497
498/// Returns file-read statistics grouped by session, agent, and path.
499pub fn path_breakdown(conn: &Connection) -> rusqlite::Result<Vec<PathBreakdownRow>> {
500    let mut stmt = conn.prepare(
501        "SELECT session_id, agent_id, rel_path, read_count, bytes_delivered,
502                size, lines, stub_count, first_seen_ts, last_read_ts
503         FROM reads
504         WHERE read_count > 0 OR stub_count > 0
505         ORDER BY session_id, agent_id, rel_path",
506    )?;
507    let rows = stmt.query_map([], |row| {
508        Ok(PathBreakdownRow {
509            session_id: row.get(0)?,
510            agent_id: row.get(1)?,
511            rel_path: row.get(2)?,
512            read_count: row.get(3)?,
513            bytes_delivered: row.get(4)?,
514            size: row.get(5)?,
515            lines: row.get(6)?,
516            stub_count: row.get(7)?,
517            first_seen_ts: row.get(8)?,
518            last_read_ts: row.get(9)?,
519        })
520    })?;
521    rows.collect()
522}
523
524/// Aggregate read-cache statistics (`stats_for`).
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub struct StatsFor {
527    /// The distinct files value.
528    pub distinct_files: i64,
529    /// The total stubs value.
530    pub total_stubs: i64,
531    /// The lines saved value.
532    pub lines_saved: i64,
533    /// The bytes saved value.
534    pub bytes_saved: i64,
535}
536
537/// Returns aggregate file-read freshness statistics.
538pub fn stats_for(conn: &Connection) -> rusqlite::Result<StatsFor> {
539    let distinct_files: i64 = conn.query_row(
540        "SELECT COUNT(DISTINCT rel_path) AS n FROM reads",
541        [],
542        |row| row.get(0),
543    )?;
544    let total_stubs: i64 = conn.query_row(
545        "SELECT COALESCE(SUM(stub_count),0) AS n FROM reads",
546        [],
547        |row| row.get(0),
548    )?;
549    let (lines_saved, bytes_saved): (i64, i64) = conn.query_row(
550        "SELECT COALESCE(SUM(lines*stub_count),0) AS lines_saved, COALESCE(SUM(size*stub_count),0) AS bytes_saved FROM reads",
551        [],
552        |row| Ok((row.get(0)?, row.get(1)?)),
553    )?;
554    Ok(StatsFor {
555        distinct_files,
556        total_stubs,
557        lines_saved,
558        bytes_saved,
559    })
560}
561
562/// Per-session read-cache statistics (`session_stats`).
563#[derive(Debug, Clone, PartialEq, Eq)]
564pub struct SessionStatsRow {
565    /// The session id value.
566    pub session_id: String,
567    /// The files value.
568    pub files: i64,
569    /// The stubs value.
570    pub stubs: i64,
571    /// The lines saved value.
572    pub lines_saved: i64,
573    /// The bytes saved value.
574    pub bytes_saved: i64,
575}
576
577/// Returns file-read statistics grouped by session.
578pub fn session_stats(conn: &Connection) -> rusqlite::Result<Vec<SessionStatsRow>> {
579    let mut stmt = conn.prepare(
580        "SELECT session_id, COUNT(*) AS files, COALESCE(SUM(stub_count),0) AS stubs,
581                COALESCE(SUM(lines*stub_count),0) AS lines_saved,
582                COALESCE(SUM(size*stub_count),0) AS bytes_saved
583         FROM reads GROUP BY session_id ORDER BY stubs DESC, session_id",
584    )?;
585    let rows = stmt.query_map([], |row| {
586        Ok(SessionStatsRow {
587            session_id: row.get(0)?,
588            files: row.get(1)?,
589            stubs: row.get(2)?,
590            lines_saved: row.get(3)?,
591            bytes_saved: row.get(4)?,
592        })
593    })?;
594    rows.collect()
595}
596
597/// One of the most-stubbed files (`top_stubbed`).
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct TopStubbedRow {
600    /// The rel path value.
601    pub rel_path: String,
602    /// The session id value.
603    pub session_id: String,
604    /// The stub count value.
605    pub stub_count: i64,
606    /// The lines value.
607    pub lines: i64,
608}
609
610/// Returns at most `n` file-read rows ordered by descending stub count.
611pub fn top_stubbed(conn: &Connection, n: i64) -> rusqlite::Result<Vec<TopStubbedRow>> {
612    let mut stmt = conn.prepare(
613        "SELECT rel_path, session_id, stub_count, lines FROM reads WHERE stub_count > 0 ORDER BY stub_count DESC, rel_path LIMIT ?",
614    )?;
615    let rows = stmt.query_map(params![n], |row| {
616        Ok(TopStubbedRow {
617            rel_path: row.get(0)?,
618            session_id: row.get(1)?,
619            stub_count: row.get(2)?,
620            lines: row.get(3)?,
621        })
622    })?;
623    rows.collect()
624}
625
626/// Deletes cached read rows. `session_id` takes priority over `older_than_days`
627/// when both are given (short-circuits before the age check). Age is
628/// `COALESCE(last_read_ts, first_seen_ts)`: legacy rows migrated without
629/// `last_read_ts` fall back to their insert time. Deliberately NOT wrapped in
630/// `with_busy_retry`.
631pub fn prune(
632    conn: &Connection,
633    older_than_days: Option<f64>,
634    session_id: Option<&str>,
635) -> rusqlite::Result<usize> {
636    if let Some(sid) = session_id {
637        return conn.execute("DELETE FROM reads WHERE session_id = ?", params![sid]);
638    }
639    if let Some(days) = older_than_days {
640        let cutoff = now_ms() - (days * 86400.0 * 1000.0) as i64;
641        return conn.execute(
642            "DELETE FROM reads WHERE COALESCE(last_read_ts, first_seen_ts) < ?",
643            params![cutoff],
644        );
645    }
646    Ok(0)
647}
648
649/// Distinct session ids whose id starts with `prefix`.
650pub fn session_ids_by_prefix(conn: &Connection, prefix: &str) -> rusqlite::Result<Vec<String>> {
651    let like = format!("{prefix}%");
652    let mut stmt = conn.prepare("SELECT DISTINCT session_id FROM reads WHERE session_id LIKE ?")?;
653    let rows = stmt.query_map(params![like], |row| row.get::<_, String>(0))?;
654    rows.collect()
655}
656
657// ---------------------------------------------------------------------------
658// content.db -- cross-repo content-addressed dedup
659// ---------------------------------------------------------------------------
660
661/// The content database path: the `SCOUT_CONTENT_DB` env var when set, otherwise
662/// `default_content_db_path()`. Only an entirely *unset* var falls back; an
663/// explicitly empty value is used verbatim. The fallback is derived at runtime
664/// from `$HOME` as `$HOME/.claude/scout/content.db`; when `HOME` is unset it
665/// degrades to a bare cwd-relative `content.db`. Set `SCOUT_CONTENT_DB` to pick
666/// the path explicitly.
667pub fn content_db_path() -> PathBuf {
668    match env::var("SCOUT_CONTENT_DB") {
669        Ok(v) => PathBuf::from(v),
670        Err(_) => default_content_db_path(),
671    }
672}
673
674fn default_content_db_path() -> PathBuf {
675    match env::var("HOME") {
676        Ok(home) => PathBuf::from(home)
677            .join(".claude")
678            .join("scout")
679            .join("content.db"),
680        Err(_) => PathBuf::from("content.db"),
681    }
682}
683
684/// Opens the content database with the same WAL + busy_timeout=5000 treatment
685/// and the same one-time agent-scope migration as `open_store`, applied to the
686/// single `content` table.
687pub fn open_content_store() -> rusqlite::Result<Connection> {
688    let conn = Connection::open(content_db_path())?;
689    conn.execute_batch("PRAGMA journal_mode = WAL")?;
690    conn.execute_batch("PRAGMA busy_timeout = 5000")?;
691    conn.execute_batch(&format!(
692        "CREATE TABLE IF NOT EXISTS content {CONTENT_SCHEMA};"
693    ))?;
694    add_agent_scope(&conn, "content", CONTENT_SCHEMA, &CONTENT_COLS)?;
695    Ok(conn)
696}
697
698/// A content-dedup row (`lookup_content`).
699#[derive(Debug, Clone, PartialEq, Eq)]
700pub struct ContentRow {
701    /// The session id value.
702    pub session_id: String,
703    /// The agent id value.
704    pub agent_id: String,
705    /// The sha256 value.
706    pub sha256: String,
707    /// The root value.
708    pub root: String,
709    /// The rel path value.
710    pub rel_path: String,
711    /// The size value.
712    pub size: i64,
713    /// The lines value.
714    pub lines: i64,
715    /// The stub count value.
716    pub stub_count: i64,
717}
718
719/// Looks up content freshness for a session, digest, and agent.
720pub fn lookup_content(
721    conn: &Connection,
722    session_id: &str,
723    sha256: &str,
724    agent_id: &str,
725) -> rusqlite::Result<Option<ContentRow>> {
726    conn.query_row(
727        "SELECT session_id, agent_id, sha256, root, rel_path, size, lines, stub_count FROM content WHERE session_id = ? AND agent_id = ? AND sha256 = ?",
728        params![session_id, agent_id, sha256],
729        |row| {
730            Ok(ContentRow {
731                session_id: row.get(0)?,
732                agent_id: row.get(1)?,
733                sha256: row.get(2)?,
734                root: row.get(3)?,
735                rel_path: row.get(4)?,
736                size: row.get(5)?,
737                lines: row.get(6)?,
738                stub_count: row.get(7)?,
739            })
740        },
741    )
742    .optional()
743}
744
745/// Arguments for `record_content`. The first path to carry this content keeps
746/// the naming rights: `ON CONFLICT DO NOTHING`.
747pub struct RecordContent<'a> {
748    /// The session id value.
749    pub session_id: &'a str,
750    /// The agent id value.
751    pub agent_id: &'a str,
752    /// The sha256 value.
753    pub sha256: &'a str,
754    /// The root value.
755    pub root: &'a str,
756    /// The rel path value.
757    pub rel_path: &'a str,
758    /// The size value.
759    pub size: i64,
760    /// The lines value.
761    pub lines: i64,
762}
763
764/// Inserts or replaces the content-freshness row described by `p`.
765pub fn record_content(conn: &Connection, p: &RecordContent) -> rusqlite::Result<()> {
766    let now = now_ms();
767    with_busy_retry(|| {
768        conn.execute(
769            "INSERT INTO content (session_id, agent_id, sha256, root, rel_path, size, lines, stub_count, first_seen_ts, last_read_ts)
770             VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
771             ON CONFLICT(session_id, agent_id, sha256) DO NOTHING",
772            params![p.session_id, p.agent_id, p.sha256, p.root, p.rel_path, p.size, p.lines, now, now],
773        )?;
774        Ok(())
775    })
776}
777
778/// Records another stubbed (cache-hit) read of content by digest.
779pub fn bump_content_stub(
780    conn: &Connection,
781    session_id: &str,
782    sha256: &str,
783    agent_id: &str,
784) -> rusqlite::Result<()> {
785    let now = now_ms();
786    with_busy_retry(|| {
787        conn.execute(
788            "UPDATE content SET stub_count = stub_count + 1, last_read_ts = ? WHERE session_id = ? AND agent_id = ? AND sha256 = ?",
789            params![now, session_id, agent_id, sha256],
790        )?;
791        Ok(())
792    })
793}
794
795/// Aggregate content-dedup statistics (`content_stats_for`).
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub struct ContentStats {
798    /// The distinct contents value.
799    pub distinct_contents: i64,
800    /// The total stubs value.
801    pub total_stubs: i64,
802    /// The bytes saved value.
803    pub bytes_saved: i64,
804}
805
806/// Returns aggregate content-freshness statistics.
807pub fn content_stats_for(conn: &Connection) -> rusqlite::Result<ContentStats> {
808    conn.query_row(
809        "SELECT COUNT(*) AS n, COALESCE(SUM(stub_count),0) AS stubs, COALESCE(SUM(size*stub_count),0) AS bytes_saved FROM content",
810        [],
811        |row| {
812            Ok(ContentStats {
813                distinct_contents: row.get(0)?,
814                total_stubs: row.get(1)?,
815                bytes_saved: row.get(2)?,
816            })
817        },
818    )
819}
820
821// ---------------------------------------------------------------------------
822// Unit tests -- retry helper, pragma read-backs, migration on a pre-migration
823// fixture, and a same-process round-trip smoke test per table. Cross-process
824// interop, concurrency, and schema comparison live in the integration suite,
825// which needs to shell out to a separate process and does not belong in a
826// `#[cfg(test)]` unit block.
827// ---------------------------------------------------------------------------
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use std::sync::atomic::{AtomicU64, Ordering};
833    use std::sync::Mutex;
834
835    static COUNTER: AtomicU64 = AtomicU64::new(0);
836    static CONTENT_ENV_LOCK: Mutex<()> = Mutex::new(());
837
838    fn unique_temp_dir(prefix: &str) -> PathBuf {
839        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
840        let dir = env::temp_dir().join(format!(
841            "scout-store-rs-{prefix}-{}-{n}",
842            std::process::id()
843        ));
844        std::fs::create_dir_all(&dir).expect("create temp dir");
845        dir
846    }
847
848    fn repo() -> PathBuf {
849        let root = unique_temp_dir("repo");
850        std::fs::create_dir_all(root.join(".scout")).unwrap();
851        root
852    }
853
854    // -- with_busy_retry ----------------------------------------------------
855
856    fn busy_err() -> rusqlite::Error {
857        rusqlite::Error::SqliteFailure(
858            rusqlite::ffi::Error {
859                code: ErrorCode::DatabaseBusy,
860                extended_code: 5,
861            },
862            Some("database is locked".to_string()),
863        )
864    }
865
866    fn locked_err() -> rusqlite::Error {
867        rusqlite::Error::SqliteFailure(
868            rusqlite::ffi::Error {
869                code: ErrorCode::DatabaseLocked,
870                extended_code: 6,
871            },
872            Some("database is locked".to_string()),
873        )
874    }
875
876    fn other_err() -> rusqlite::Error {
877        rusqlite::Error::SqliteFailure(
878            rusqlite::ffi::Error {
879                code: ErrorCode::Unknown,
880                extended_code: 1,
881            },
882            Some("near \"SELCT\": syntax error".to_string()),
883        )
884    }
885
886    #[test]
887    fn retries_on_busy_then_succeeds() {
888        let calls = std::cell::Cell::new(0);
889        let result = with_busy_retry_opts(5, 0, || {
890            calls.set(calls.get() + 1);
891            if calls.get() < 3 {
892                Err(busy_err())
893            } else {
894                Ok("ok")
895            }
896        });
897        assert_eq!(result.unwrap(), "ok");
898        assert_eq!(calls.get(), 3);
899    }
900
901    #[test]
902    fn retries_on_locked_then_succeeds() {
903        let calls = std::cell::Cell::new(0);
904        let result = with_busy_retry_opts(5, 0, || {
905            calls.set(calls.get() + 1);
906            if calls.get() < 2 {
907                Err(locked_err())
908            } else {
909                Ok("ok")
910            }
911        });
912        assert_eq!(result.unwrap(), "ok");
913        assert_eq!(calls.get(), 2);
914    }
915
916    #[test]
917    fn other_error_rethrows_immediately_no_retry() {
918        let calls = std::cell::Cell::new(0);
919        let result: rusqlite::Result<()> = with_busy_retry_opts(5, 0, || {
920            calls.set(calls.get() + 1);
921            Err(other_err())
922        });
923        assert!(result.is_err());
924        assert_eq!(calls.get(), 1);
925    }
926
927    #[test]
928    fn exhaustion_rethrows_the_last_busy_error() {
929        let calls = std::cell::Cell::new(0);
930        let result: rusqlite::Result<()> = with_busy_retry_opts(3, 0, || {
931            calls.set(calls.get() + 1);
932            Err(busy_err())
933        });
934        assert_eq!(calls.get(), 3);
935        match result {
936            Err(rusqlite::Error::SqliteFailure(e, _)) => {
937                assert_eq!(e.code, ErrorCode::DatabaseBusy)
938            }
939            other => panic!("expected a busy SqliteFailure, got {other:?}"),
940        }
941    }
942
943    // -- pragma read-backs ----------------------------------------------
944
945    #[test]
946    fn open_store_enables_wal_and_5s_busy_timeout() {
947        let conn = open_store(&repo()).unwrap();
948        let journal_mode: String = conn
949            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
950            .unwrap();
951        assert_eq!(journal_mode, "wal");
952        let timeout: i64 = conn
953            .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
954            .unwrap();
955        assert_eq!(timeout, 5000);
956    }
957
958    #[test]
959    fn open_content_store_enables_wal_and_5s_busy_timeout() {
960        let _guard = CONTENT_ENV_LOCK.lock().unwrap();
961        let dir = unique_temp_dir("content-pragma");
962        env::set_var("SCOUT_CONTENT_DB", dir.join("content.db"));
963        let conn = open_content_store().unwrap();
964        let journal_mode: String = conn
965            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
966            .unwrap();
967        assert_eq!(journal_mode, "wal");
968        let timeout: i64 = conn
969            .query_row("PRAGMA busy_timeout", [], |r| r.get(0))
970            .unwrap();
971        assert_eq!(timeout, 5000);
972        env::remove_var("SCOUT_CONTENT_DB");
973    }
974
975    // -- migration on a pre-migration fixture ----------------------------
976
977    // Builds a `reads` table shaped like the database BEFORE agent scoping
978    // and the read_count/bytes_delivered spend columns existed (the historical
979    // starting point `add_agent_scope`'s doc comment describes), then opens it
980    // through `open_store` and asserts every
981    // migration branch fired: agent_id present (defaulted to '' on old
982    // rows), last_read_ts present, read_count/bytes_delivered backfilled to
983    // the provable floor (1 / size) for a row that carries a real sha256.
984    #[test]
985    fn open_store_migrates_a_pre_agent_scope_pre_spend_fixture() {
986        let root = repo();
987        let db_path = repo::scout_dir(&root).join("cache.db");
988        {
989            let conn = Connection::open(&db_path).unwrap();
990            conn.execute_batch(
991                "CREATE TABLE reads (
992                    session_id TEXT NOT NULL,
993                    rel_path TEXT NOT NULL,
994                    sha256 TEXT NOT NULL,
995                    size INTEGER NOT NULL,
996                    mtime INTEGER NOT NULL,
997                    first_seen_ts INTEGER NOT NULL,
998                    lines INTEGER NOT NULL,
999                    stub_count INTEGER NOT NULL DEFAULT 0,
1000                    PRIMARY KEY (session_id, rel_path)
1001                )",
1002            )
1003            .unwrap();
1004            conn.execute(
1005                "INSERT INTO reads (session_id, rel_path, sha256, size, mtime, first_seen_ts, lines, stub_count)
1006                 VALUES ('legacy-sess', 'old.ts', 'deadbeef', 42, 100, 900, 7, 3)",
1007                [],
1008            )
1009            .unwrap();
1010        }
1011
1012        let conn = open_store(&root).unwrap();
1013        let cols = columns(&conn, "reads").unwrap();
1014        assert!(cols.iter().any(|c| c == "agent_id"));
1015        assert!(cols.iter().any(|c| c == "last_read_ts"));
1016        assert!(cols.iter().any(|c| c == "read_count"));
1017        assert!(cols.iter().any(|c| c == "bytes_delivered"));
1018
1019        let row = lookup_read(&conn, "legacy-sess", "old.ts", "")
1020            .unwrap()
1021            .expect("migrated row present under agent_id ''");
1022        assert_eq!(row.sha256, "deadbeef");
1023        assert_eq!(row.stub_count, 3);
1024
1025        let (read_count, bytes_delivered): (i64, i64) = conn
1026            .query_row(
1027                "SELECT read_count, bytes_delivered FROM reads WHERE session_id = 'legacy-sess' AND rel_path = 'old.ts'",
1028                [],
1029                |r| Ok((r.get(0)?, r.get(1)?)),
1030            )
1031            .unwrap();
1032        assert_eq!(
1033            read_count, 1,
1034            "backfilled to the provable floor, not left at 0"
1035        );
1036        assert_eq!(
1037            bytes_delivered, 42,
1038            "backfilled to size for a row with a real sha256"
1039        );
1040    }
1041
1042    #[test]
1043    fn open_content_store_migrates_a_pre_agent_scope_fixture() {
1044        let _guard = CONTENT_ENV_LOCK.lock().unwrap();
1045        let dir = unique_temp_dir("content-migrate");
1046        let db_path = dir.join("content.db");
1047        {
1048            let conn = Connection::open(&db_path).unwrap();
1049            conn.execute_batch(
1050                "CREATE TABLE content (
1051                    session_id TEXT NOT NULL,
1052                    sha256 TEXT NOT NULL,
1053                    root TEXT NOT NULL,
1054                    rel_path TEXT NOT NULL,
1055                    size INTEGER NOT NULL,
1056                    lines INTEGER NOT NULL,
1057                    stub_count INTEGER NOT NULL DEFAULT 0,
1058                    first_seen_ts INTEGER NOT NULL,
1059                    last_read_ts INTEGER,
1060                    PRIMARY KEY (session_id, sha256)
1061                )",
1062            )
1063            .unwrap();
1064            conn.execute(
1065                "INSERT INTO content (session_id, sha256, root, rel_path, size, lines, stub_count, first_seen_ts, last_read_ts)
1066                 VALUES ('legacy-sess', 'cafef00d', '/r1', 'old.ts', 10, 2, 1, 900, 900)",
1067                [],
1068            )
1069            .unwrap();
1070        }
1071        env::set_var("SCOUT_CONTENT_DB", &db_path);
1072        let conn = open_content_store().unwrap();
1073        let cols = columns(&conn, "content").unwrap();
1074        assert!(cols.iter().any(|c| c == "agent_id"));
1075        let row = lookup_content(&conn, "legacy-sess", "cafef00d", "")
1076            .unwrap()
1077            .expect("migrated row present under agent_id ''");
1078        assert_eq!(row.root, "/r1");
1079        assert_eq!(row.rel_path, "old.ts");
1080        env::remove_var("SCOUT_CONTENT_DB");
1081    }
1082
1083    // -- round-trip smoke tests, one per table --------------------------
1084
1085    #[test]
1086    fn reads_round_trip() {
1087        let conn = open_store(&repo()).unwrap();
1088        assert!(lookup_read(&conn, "s1", "a.ts", "").unwrap().is_none());
1089        record_fresh(
1090            &conn,
1091            &RecordFresh {
1092                session_id: "s1",
1093                agent_id: "",
1094                rel_path: "a.ts",
1095                sha256: "abc",
1096                size: 3,
1097                mtime: 100,
1098                lines: 1,
1099                delivered: true,
1100            },
1101        )
1102        .unwrap();
1103        let row = lookup_read(&conn, "s1", "a.ts", "").unwrap().unwrap();
1104        assert_eq!(row.sha256, "abc");
1105        assert_eq!(row.stub_count, 0);
1106        bump_stub(&conn, "s1", "a.ts", "").unwrap();
1107        bump_stub(&conn, "s1", "a.ts", "").unwrap();
1108        assert_eq!(
1109            lookup_read(&conn, "s1", "a.ts", "")
1110                .unwrap()
1111                .unwrap()
1112                .stub_count,
1113            2
1114        );
1115        record_fresh(
1116            &conn,
1117            &RecordFresh {
1118                session_id: "s1",
1119                agent_id: "",
1120                rel_path: "a.ts",
1121                sha256: "def",
1122                size: 4,
1123                mtime: 200,
1124                lines: 1,
1125                delivered: true,
1126            },
1127        )
1128        .unwrap();
1129        let row = lookup_read(&conn, "s1", "a.ts", "").unwrap().unwrap();
1130        assert_eq!(row.stub_count, 0);
1131        assert_eq!(row.sha256, "def");
1132    }
1133
1134    #[test]
1135    fn sessions_are_isolated() {
1136        let conn = open_store(&repo()).unwrap();
1137        record_fresh(
1138            &conn,
1139            &RecordFresh {
1140                session_id: "s1",
1141                agent_id: "",
1142                rel_path: "a.ts",
1143                sha256: "abc",
1144                size: 3,
1145                mtime: 100,
1146                lines: 1,
1147                delivered: true,
1148            },
1149        )
1150        .unwrap();
1151        assert!(lookup_read(&conn, "s2", "a.ts", "").unwrap().is_none());
1152    }
1153
1154    #[test]
1155    fn stats_for_counts_distinct_files_and_total_stubs() {
1156        let conn = open_store(&repo()).unwrap();
1157        record_fresh(
1158            &conn,
1159            &RecordFresh {
1160                session_id: "s1",
1161                agent_id: "",
1162                rel_path: "a.ts",
1163                sha256: "abc",
1164                size: 3,
1165                mtime: 100,
1166                lines: 1,
1167                delivered: true,
1168            },
1169        )
1170        .unwrap();
1171        record_fresh(
1172            &conn,
1173            &RecordFresh {
1174                session_id: "s1",
1175                agent_id: "",
1176                rel_path: "b.ts",
1177                sha256: "xyz",
1178                size: 3,
1179                mtime: 100,
1180                lines: 1,
1181                delivered: true,
1182            },
1183        )
1184        .unwrap();
1185        bump_stub(&conn, "s1", "a.ts", "").unwrap();
1186        let s = stats_for(&conn).unwrap();
1187        assert_eq!(s.distinct_files, 2);
1188        assert_eq!(s.total_stubs, 1);
1189    }
1190
1191    #[test]
1192    fn record_spend_never_produces_a_matchable_sha_and_tracks_only_spend() {
1193        let conn = open_store(&repo()).unwrap();
1194        record_spend(
1195            &conn,
1196            &RecordSpend {
1197                session_id: "s1",
1198                agent_id: "",
1199                rel_path: "big.ts",
1200                size: 500,
1201            },
1202        )
1203        .unwrap();
1204        let row = lookup_read(&conn, "s1", "big.ts", "").unwrap().unwrap();
1205        assert_eq!(row.sha256, "");
1206        assert_eq!(row.stub_count, 0);
1207    }
1208
1209    // `delivered: false` is the cross-repo content-stub caller: the row is
1210    // written (so a later repeat takes the cheap path cache) but the bytes
1211    // never reached the model, so bytes_delivered must not move even though
1212    // read_count still does.
1213    #[test]
1214    fn record_fresh_not_delivered_writes_the_row_without_moving_bytes_delivered() {
1215        let conn = open_store(&repo()).unwrap();
1216        record_fresh(
1217            &conn,
1218            &RecordFresh {
1219                session_id: "s1",
1220                agent_id: "",
1221                rel_path: "cross.ts",
1222                sha256: "abc",
1223                size: 999,
1224                mtime: 1,
1225                lines: 3,
1226                delivered: false,
1227            },
1228        )
1229        .unwrap();
1230        let (read_count, bytes_delivered): (i64, i64) = conn
1231            .query_row(
1232                "SELECT read_count, bytes_delivered FROM reads WHERE session_id = 's1' AND rel_path = 'cross.ts'",
1233                [],
1234                |r| Ok((r.get(0)?, r.get(1)?)),
1235            )
1236            .unwrap();
1237        assert_eq!(read_count, 1);
1238        assert_eq!(
1239            bytes_delivered, 0,
1240            "delivered=false must never move bytes_delivered"
1241        );
1242
1243        // A subsequent delivered=true call accumulates on top, matching the
1244        // ON CONFLICT arithmetic used by every caller regardless of delivered.
1245        record_fresh(
1246            &conn,
1247            &RecordFresh {
1248                session_id: "s1",
1249                agent_id: "",
1250                rel_path: "cross.ts",
1251                sha256: "abc",
1252                size: 999,
1253                mtime: 2,
1254                lines: 3,
1255                delivered: true,
1256            },
1257        )
1258        .unwrap();
1259        let (read_count, bytes_delivered): (i64, i64) = conn
1260            .query_row(
1261                "SELECT read_count, bytes_delivered FROM reads WHERE session_id = 's1' AND rel_path = 'cross.ts'",
1262                [],
1263                |r| Ok((r.get(0)?, r.get(1)?)),
1264            )
1265            .unwrap();
1266        assert_eq!(read_count, 2);
1267        assert_eq!(bytes_delivered, 999);
1268    }
1269
1270    #[test]
1271    fn bash_round_trip() {
1272        let conn = open_store(&repo()).unwrap();
1273        assert!(lookup_bash(&conn, "s1", "cat f.ts", "").unwrap().is_none());
1274        record_bash_fresh(
1275            &conn,
1276            &RecordBashFresh {
1277                session_id: "s1",
1278                agent_id: "",
1279                cache_key: "cat f.ts",
1280                sha256: "abc",
1281                size: 3,
1282                lines: 1,
1283            },
1284        )
1285        .unwrap();
1286        let row = lookup_bash(&conn, "s1", "cat f.ts", "").unwrap().unwrap();
1287        assert_eq!(row.sha256, "abc");
1288        bump_bash_stub(&conn, "s1", "cat f.ts", "").unwrap();
1289        assert_eq!(
1290            lookup_bash(&conn, "s1", "cat f.ts", "")
1291                .unwrap()
1292                .unwrap()
1293                .stub_count,
1294            1
1295        );
1296        let stats = bash_stats_for(&conn).unwrap();
1297        assert_eq!(stats.commands_tracked, 1);
1298        assert_eq!(stats.total_stubs, 1);
1299        assert_eq!(stats.bytes_saved, 3);
1300    }
1301
1302    #[test]
1303    fn content_round_trip_and_conflict_keeps_first_path() {
1304        let _guard = CONTENT_ENV_LOCK.lock().unwrap();
1305        let dir = unique_temp_dir("content-roundtrip");
1306        env::set_var("SCOUT_CONTENT_DB", dir.join("content.db"));
1307        let conn = open_content_store().unwrap();
1308        assert!(lookup_content(&conn, "s", "dup", "").unwrap().is_none());
1309        record_content(
1310            &conn,
1311            &RecordContent {
1312                session_id: "s",
1313                agent_id: "",
1314                sha256: "dup",
1315                root: "/r1",
1316                rel_path: "first.ts",
1317                size: 10,
1318                lines: 1,
1319            },
1320        )
1321        .unwrap();
1322        record_content(
1323            &conn,
1324            &RecordContent {
1325                session_id: "s",
1326                agent_id: "",
1327                sha256: "dup",
1328                root: "/r2",
1329                rel_path: "second.ts",
1330                size: 10,
1331                lines: 1,
1332            },
1333        )
1334        .unwrap();
1335        let hit = lookup_content(&conn, "s", "dup", "").unwrap().unwrap();
1336        assert_eq!(hit.rel_path, "first.ts");
1337        assert_eq!(hit.root, "/r1");
1338        bump_content_stub(&conn, "s", "dup", "").unwrap();
1339        let stats = content_stats_for(&conn).unwrap();
1340        assert_eq!(stats.distinct_contents, 1);
1341        assert_eq!(stats.total_stubs, 1);
1342        env::remove_var("SCOUT_CONTENT_DB");
1343    }
1344
1345    #[test]
1346    fn content_db_path_uses_runtime_home_and_honours_override() {
1347        let _guard = CONTENT_ENV_LOCK.lock().unwrap();
1348        let old_home = env::var_os("HOME");
1349        let old_override = env::var_os("SCOUT_CONTENT_DB");
1350        let dir = unique_temp_dir("content-path");
1351        let target = dir.join("content.db");
1352
1353        env::remove_var("SCOUT_CONTENT_DB");
1354        env::set_var("HOME", &dir);
1355        assert_eq!(content_db_path(), dir.join(".claude/scout/content.db"));
1356
1357        env::set_var("SCOUT_CONTENT_DB", &target);
1358        assert_eq!(content_db_path(), target);
1359
1360        match old_override {
1361            Some(value) => env::set_var("SCOUT_CONTENT_DB", value),
1362            None => env::remove_var("SCOUT_CONTENT_DB"),
1363        }
1364        match old_home {
1365            Some(value) => env::set_var("HOME", value),
1366            None => env::remove_var("HOME"),
1367        }
1368    }
1369
1370    #[test]
1371    fn prune_by_session_and_by_age() {
1372        let conn = open_store(&repo()).unwrap();
1373        record_fresh(
1374            &conn,
1375            &RecordFresh {
1376                session_id: "s1",
1377                agent_id: "",
1378                rel_path: "a.ts",
1379                sha256: "abc",
1380                size: 3,
1381                mtime: 100,
1382                lines: 1,
1383                delivered: true,
1384            },
1385        )
1386        .unwrap();
1387        record_fresh(
1388            &conn,
1389            &RecordFresh {
1390                session_id: "s2",
1391                agent_id: "",
1392                rel_path: "b.ts",
1393                sha256: "def",
1394                size: 3,
1395                mtime: 100,
1396                lines: 1,
1397                delivered: true,
1398            },
1399        )
1400        .unwrap();
1401        let deleted = prune(&conn, None, Some("s1")).unwrap();
1402        assert_eq!(deleted, 1);
1403        assert!(lookup_read(&conn, "s1", "a.ts", "").unwrap().is_none());
1404        assert!(lookup_read(&conn, "s2", "b.ts", "").unwrap().is_some());
1405
1406        // olderThanDays with a cutoff before any row's timestamp deletes nothing.
1407        let deleted = prune(&conn, Some(9999.0), None).unwrap();
1408        assert_eq!(deleted, 0);
1409        // A negative window (cutoff in the future) deletes everything remaining.
1410        let deleted = prune(&conn, Some(-1.0), None).unwrap();
1411        assert_eq!(deleted, 1);
1412    }
1413
1414    #[test]
1415    fn session_ids_by_prefix_matches() {
1416        let conn = open_store(&repo()).unwrap();
1417        record_fresh(
1418            &conn,
1419            &RecordFresh {
1420                session_id: "abc123",
1421                agent_id: "",
1422                rel_path: "a.ts",
1423                sha256: "x",
1424                size: 1,
1425                mtime: 1,
1426                lines: 1,
1427                delivered: true,
1428            },
1429        )
1430        .unwrap();
1431        record_fresh(
1432            &conn,
1433            &RecordFresh {
1434                session_id: "abcxyz",
1435                agent_id: "",
1436                rel_path: "b.ts",
1437                sha256: "y",
1438                size: 1,
1439                mtime: 1,
1440                lines: 1,
1441                delivered: true,
1442            },
1443        )
1444        .unwrap();
1445        record_fresh(
1446            &conn,
1447            &RecordFresh {
1448                session_id: "zzz999",
1449                agent_id: "",
1450                rel_path: "c.ts",
1451                sha256: "z",
1452                size: 1,
1453                mtime: 1,
1454                lines: 1,
1455                delivered: true,
1456            },
1457        )
1458        .unwrap();
1459        let mut matches = session_ids_by_prefix(&conn, "abc").unwrap();
1460        matches.sort();
1461        assert_eq!(matches, vec!["abc123".to_string(), "abcxyz".to_string()]);
1462    }
1463
1464    #[test]
1465    fn top_stubbed_orders_by_stub_count_desc_then_path() {
1466        let conn = open_store(&repo()).unwrap();
1467        record_fresh(
1468            &conn,
1469            &RecordFresh {
1470                session_id: "s1",
1471                agent_id: "",
1472                rel_path: "a.ts",
1473                sha256: "x",
1474                size: 1,
1475                mtime: 1,
1476                lines: 5,
1477                delivered: true,
1478            },
1479        )
1480        .unwrap();
1481        record_fresh(
1482            &conn,
1483            &RecordFresh {
1484                session_id: "s1",
1485                agent_id: "",
1486                rel_path: "b.ts",
1487                sha256: "y",
1488                size: 1,
1489                mtime: 1,
1490                lines: 5,
1491                delivered: true,
1492            },
1493        )
1494        .unwrap();
1495        bump_stub(&conn, "s1", "a.ts", "").unwrap();
1496        bump_stub(&conn, "s1", "b.ts", "").unwrap();
1497        bump_stub(&conn, "s1", "b.ts", "").unwrap();
1498        let top = top_stubbed(&conn, 10).unwrap();
1499        assert_eq!(top.len(), 2);
1500        assert_eq!(top[0].rel_path, "b.ts");
1501        assert_eq!(top[0].stub_count, 2);
1502        assert_eq!(top[1].rel_path, "a.ts");
1503    }
1504
1505    #[test]
1506    fn path_breakdown_excludes_rows_with_no_reads() {
1507        let conn = open_store(&repo()).unwrap();
1508        // record_spend gives read_count=1 immediately, so no zero-read row
1509        // is producible through the public write API alone -- insert a raw
1510        // shell row directly to prove the WHERE clause excludes it.
1511        conn.execute(
1512            "INSERT INTO reads (session_id, agent_id, rel_path, sha256, size, mtime, first_seen_ts, lines, stub_count, read_count, bytes_delivered)
1513             VALUES ('s1', '', 'inert.ts', '', 0, 0, 1, 0, 0, 0, 0)",
1514            [],
1515        )
1516        .unwrap();
1517        record_fresh(
1518            &conn,
1519            &RecordFresh {
1520                session_id: "s1",
1521                agent_id: "",
1522                rel_path: "active.ts",
1523                sha256: "abc",
1524                size: 1,
1525                mtime: 1,
1526                lines: 1,
1527                delivered: true,
1528            },
1529        )
1530        .unwrap();
1531        let rows = path_breakdown(&conn).unwrap();
1532        assert_eq!(rows.len(), 1);
1533        assert_eq!(rows[0].rel_path, "active.ts");
1534    }
1535}