1use 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
25const 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
116fn 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
130pub 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
157pub 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
166fn 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
178fn 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
188fn 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
222pub 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#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct StubRow {
267 pub sha256: String,
269 pub lines: i64,
271 pub stub_count: i64,
273}
274
275pub 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
290pub struct RecordBashFresh<'a> {
292 pub session_id: &'a str,
294 pub agent_id: &'a str,
296 pub cache_key: &'a str,
298 pub sha256: &'a str,
300 pub size: i64,
302 pub lines: i64,
304}
305
306pub 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
322pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct BashStats {
342 pub commands_tracked: i64,
344 pub total_stubs: i64,
346 pub bytes_saved: i64,
348}
349
350pub 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
365pub 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
380pub struct RecordFresh<'a> {
384 pub session_id: &'a str,
386 pub agent_id: &'a str,
388 pub rel_path: &'a str,
390 pub sha256: &'a str,
392 pub size: i64,
394 pub mtime: i64,
396 pub lines: i64,
398 pub delivered: bool,
400}
401
402pub 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
421pub struct RecordSpend<'a> {
427 pub session_id: &'a str,
429 pub agent_id: &'a str,
431 pub rel_path: &'a str,
433 pub size: i64,
435}
436
437pub 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
454pub 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#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct PathBreakdownRow {
476 pub session_id: String,
478 pub agent_id: String,
480 pub rel_path: String,
482 pub read_count: i64,
484 pub bytes_delivered: i64,
486 pub size: i64,
488 pub lines: i64,
490 pub stub_count: i64,
492 pub first_seen_ts: i64,
494 pub last_read_ts: Option<i64>,
496}
497
498pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub struct StatsFor {
527 pub distinct_files: i64,
529 pub total_stubs: i64,
531 pub lines_saved: i64,
533 pub bytes_saved: i64,
535}
536
537pub 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#[derive(Debug, Clone, PartialEq, Eq)]
564pub struct SessionStatsRow {
565 pub session_id: String,
567 pub files: i64,
569 pub stubs: i64,
571 pub lines_saved: i64,
573 pub bytes_saved: i64,
575}
576
577pub 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#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct TopStubbedRow {
600 pub rel_path: String,
602 pub session_id: String,
604 pub stub_count: i64,
606 pub lines: i64,
608}
609
610pub 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
626pub 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
649pub 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
657pub 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
684pub 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#[derive(Debug, Clone, PartialEq, Eq)]
700pub struct ContentRow {
701 pub session_id: String,
703 pub agent_id: String,
705 pub sha256: String,
707 pub root: String,
709 pub rel_path: String,
711 pub size: i64,
713 pub lines: i64,
715 pub stub_count: i64,
717}
718
719pub 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
745pub struct RecordContent<'a> {
748 pub session_id: &'a str,
750 pub agent_id: &'a str,
752 pub sha256: &'a str,
754 pub root: &'a str,
756 pub rel_path: &'a str,
758 pub size: i64,
760 pub lines: i64,
762}
763
764pub 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
778pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub struct ContentStats {
798 pub distinct_contents: i64,
800 pub total_stubs: i64,
802 pub bytes_saved: i64,
804}
805
806pub 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#[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 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 #[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 #[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 #[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 #[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 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 let deleted = prune(&conn, Some(9999.0), None).unwrap();
1408 assert_eq!(deleted, 0);
1409 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 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}