use std::path::Path;
use rusqlite::Connection;
use super::{paths::CachePaths, Result};
pub const SCHEMA_VERSION: i64 = 1;
pub fn open(paths: &CachePaths) -> Result<Connection> {
open_at(&paths.catalog_db)
}
pub fn open_at(path: &Path) -> Result<Connection> {
let conn = Connection::open(path)?;
super::paths::ensure_file_600(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
let current_version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))?;
if current_version == 0 {
create_schema(&conn)?;
conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
} else if current_version != SCHEMA_VERSION {
return Err(super::DaemonError::other(format!(
"catalog.db schema version mismatch: file is {}, daemon expects {}",
current_version, SCHEMA_VERSION
)));
}
Ok(conn)
}
fn create_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS plugins (
name TEXT PRIMARY KEY,
version TEXT,
source TEXT, -- 'zinit' / 'omz' / 'antigen' / 'manual' / 'system' / etc.
installed_at INTEGER, -- ns since epoch
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS plugin_deps (
plugin TEXT NOT NULL,
dep TEXT NOT NULL,
constraint_ TEXT, -- semver/glob constraint expression
PRIMARY KEY (plugin, dep),
FOREIGN KEY (plugin) REFERENCES plugins(name) ON DELETE CASCADE
);
-- One row per cacheable named entity (function, completion handler, etc.).
CREATE TABLE IF NOT EXISTS entries (
fq_name TEXT PRIMARY KEY,
plugin_id TEXT, -- shard slug, '' for system / orphan
kind TEXT NOT NULL, -- 'function' / 'completion' / 'autoload' / 'hook' / 'alias'
image_path TEXT, -- absolute path of the rkyv shard
byte_offset INTEGER, -- offset within shard (rkyv slice start)
source_loc TEXT, -- 'file.zsh:line' for traceability
bytecode BLOB -- compiled output (queryable copy)
);
CREATE INDEX IF NOT EXISTS entries_plugin_idx ON entries(plugin_id);
CREATE INDEX IF NOT EXISTS entries_kind_idx ON entries(kind);
CREATE TABLE IF NOT EXISTS hooks (
kind TEXT NOT NULL, -- 'precmd' / 'preexec' / 'chpwd' / 'periodic' / etc.
name TEXT NOT NULL, -- the hook's registered name
fq_name TEXT NOT NULL, -- target entry in `entries`
PRIMARY KEY (kind, name)
);
CREATE INDEX IF NOT EXISTS hooks_fqname_idx ON hooks(fq_name);
-- Frecency / call-count / wallclock cost. Survives entries rebuild via
-- ON CONFLICT DO UPDATE keyed on fq_name.
CREATE TABLE IF NOT EXISTS entry_stats (
fq_name TEXT PRIMARY KEY,
last_called_at INTEGER,
call_count INTEGER NOT NULL DEFAULT 0,
total_ns INTEGER NOT NULL DEFAULT 0
);
-- Files the daemon has parsed + bytecode-cached, looked up by absolute path.
-- Covers: `zshrs FILE`, `source FILE`, .zshrc, plugin entry-points, autoload files.
CREATE TABLE IF NOT EXISTS compiled_files (
path TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- 'script' / 'source' / 'zshrc' / 'plugin_init' / 'autoload'
mtime INTEGER, -- ns since epoch
inode INTEGER,
hash BLOB, -- content hash
bytecode BLOB,
last_used_at INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
bytes_in INTEGER, -- source size
bytes_out INTEGER, -- bytecode size
sensitive INTEGER NOT NULL DEFAULT 0, -- 1 if heuristics flagged secrets
parent_paths TEXT -- JSON array of files that transitively included this
);
CREATE INDEX IF NOT EXISTS compiled_files_kind_idx ON compiled_files(kind);
CREATE INDEX IF NOT EXISTS compiled_files_sensitive_idx ON compiled_files(sensitive);
"#,
)
}
#[derive(serde::Serialize, Debug)]
pub struct CatalogSummary {
pub schema_version: i64,
pub plugin_count: i64,
pub entries_count: i64,
pub hooks_count: i64,
pub entry_stats_count: i64,
pub compiled_files_count: i64,
pub size_bytes: u64,
}
pub fn summary(conn: &Connection, db_path: &Path) -> Result<CatalogSummary> {
let schema_version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
let plugin_count: i64 = conn.query_row("SELECT COUNT(*) FROM plugins", [], |r| r.get(0))?;
let entries_count: i64 = conn.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?;
let hooks_count: i64 = conn.query_row("SELECT COUNT(*) FROM hooks", [], |r| r.get(0))?;
let entry_stats_count: i64 =
conn.query_row("SELECT COUNT(*) FROM entry_stats", [], |r| r.get(0))?;
let compiled_files_count: i64 =
conn.query_row("SELECT COUNT(*) FROM compiled_files", [], |r| r.get(0))?;
let size_bytes = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
Ok(CatalogSummary {
schema_version,
plugin_count,
entries_count,
hooks_count,
entry_stats_count,
compiled_files_count,
size_bytes,
})
}
pub fn integrity_check(conn: &Connection) -> Result<bool> {
let result: String = conn.query_row("PRAGMA integrity_check", [], |r| r.get::<_, String>(0))?;
Ok(result == "ok")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fresh_paths() -> (TempDir, CachePaths) {
let tmp = TempDir::new().unwrap();
let paths = CachePaths::with_root(tmp.path().join("zshrs"));
paths.ensure_dirs().unwrap();
(tmp, paths)
}
#[test]
fn open_creates_schema_at_version_1() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, SCHEMA_VERSION);
}
#[test]
fn open_idempotent() {
let (_tmp, paths) = fresh_paths();
{
let _c = open(&paths).unwrap();
}
let _c = open(&paths).unwrap();
}
#[test]
fn schema_has_expected_tables() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
tables.retain(|n| !n.starts_with("sqlite_"));
let expected = [
"compiled_files",
"entries",
"entry_stats",
"hooks",
"plugin_deps",
"plugins",
];
for want in expected {
assert!(tables.contains(&want.to_string()), "missing table {}", want);
}
}
#[test]
fn summary_starts_empty() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
let s = summary(&conn, &paths.catalog_db).unwrap();
assert_eq!(s.schema_version, SCHEMA_VERSION);
assert_eq!(s.plugin_count, 0);
assert_eq!(s.entries_count, 0);
assert_eq!(s.hooks_count, 0);
assert_eq!(s.entry_stats_count, 0);
assert_eq!(s.compiled_files_count, 0);
assert!(s.size_bytes > 0);
}
#[test]
fn integrity_check_passes_on_fresh_db() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
assert!(integrity_check(&conn).unwrap());
}
#[test]
fn entries_insert_and_select() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
conn.execute(
"INSERT INTO entries (fq_name, plugin_id, kind, image_path, byte_offset, source_loc) \
VALUES (?, ?, ?, ?, ?, ?)",
rusqlite::params!["_git", "zpwr", "completion", "/tmp/zpwr.rkyv", 0, "_git:1"],
)
.unwrap();
let s = summary(&conn, &paths.catalog_db).unwrap();
assert_eq!(s.entries_count, 1);
}
#[test]
fn entry_stats_on_conflict_update() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
conn.execute(
"INSERT INTO entry_stats (fq_name, last_called_at, call_count, total_ns) \
VALUES (?, ?, ?, ?)",
rusqlite::params!["_git", 100i64, 1i64, 1000i64],
)
.unwrap();
conn.execute(
"INSERT INTO entry_stats (fq_name, last_called_at, call_count, total_ns) \
VALUES (?, ?, ?, ?) \
ON CONFLICT(fq_name) DO UPDATE SET \
last_called_at = excluded.last_called_at, \
call_count = entry_stats.call_count + excluded.call_count, \
total_ns = entry_stats.total_ns + excluded.total_ns",
rusqlite::params!["_git", 200i64, 1i64, 1500i64],
)
.unwrap();
let (last, count, total): (i64, i64, i64) = conn
.query_row(
"SELECT last_called_at, call_count, total_ns FROM entry_stats WHERE fq_name=?",
rusqlite::params!["_git"],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(last, 200);
assert_eq!(count, 2);
assert_eq!(total, 2500);
}
#[test]
fn compiled_files_kind_index() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
conn.execute(
"INSERT INTO compiled_files (path, kind, mtime, inode, bytes_in, bytes_out, sensitive) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
"/Users/wizard/.zpwr/local/.tokens.sh",
"source",
1735305600000000000i64,
123456i64,
12000i64,
40000i64,
1i64
],
)
.unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM compiled_files WHERE sensitive=1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn wal_mode_enabled() {
let (_tmp, paths) = fresh_paths();
let conn = open(&paths).unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert_eq!(mode, "wal");
}
}