327 lines
11 KiB
Rust
327 lines
11 KiB
Rust
// ctx — Read-only access to the Claude Code context manager database
|
|
// Database: ~/.claude-context/context.db (managed by ctx CLI tool)
|
|
|
|
use rusqlite::{Connection, params};
|
|
use serde::Serialize;
|
|
use std::sync::Mutex;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CtxProject {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub work_dir: Option<String>,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CtxEntry {
|
|
pub project: String,
|
|
pub key: String,
|
|
pub value: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct CtxSummary {
|
|
pub project: String,
|
|
pub summary: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
pub struct CtxDb {
|
|
conn: Mutex<Option<Connection>>,
|
|
}
|
|
|
|
impl CtxDb {
|
|
fn db_path() -> std::path::PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".claude-context")
|
|
.join("context.db")
|
|
}
|
|
|
|
pub fn new() -> Self {
|
|
let db_path = Self::db_path();
|
|
|
|
let conn = if db_path.exists() {
|
|
Connection::open_with_flags(
|
|
&db_path,
|
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
).ok()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Self { conn: Mutex::new(conn) }
|
|
}
|
|
|
|
/// Create the context database directory and schema, then open a read-only connection.
|
|
pub fn init_db(&self) -> Result<(), String> {
|
|
let db_path = Self::db_path();
|
|
|
|
// Create parent directory
|
|
if let Some(parent) = db_path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|e| format!("Failed to create directory: {e}"))?;
|
|
}
|
|
|
|
// Open read-write to create schema
|
|
let conn = Connection::open(&db_path)
|
|
.map_err(|e| format!("Failed to create database: {e}"))?;
|
|
|
|
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode failed: {e}"))?;
|
|
|
|
conn.execute_batch(
|
|
"CREATE TABLE IF NOT EXISTS sessions (
|
|
name TEXT PRIMARY KEY,
|
|
description TEXT,
|
|
work_dir TEXT,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS contexts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT DEFAULT (datetime('now')),
|
|
UNIQUE(project, key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS shared (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS summaries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
project TEXT NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS contexts_fts USING fts5(
|
|
project, key, value, content=contexts, content_rowid=id
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS shared_fts USING fts5(
|
|
key, value, content=shared
|
|
);
|
|
|
|
CREATE TRIGGER IF NOT EXISTS contexts_ai AFTER INSERT ON contexts BEGIN
|
|
INSERT INTO contexts_fts(rowid, project, key, value)
|
|
VALUES (new.id, new.project, new.key, new.value);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS contexts_ad AFTER DELETE ON contexts BEGIN
|
|
INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value)
|
|
VALUES ('delete', old.id, old.project, old.key, old.value);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS contexts_au AFTER UPDATE ON contexts BEGIN
|
|
INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value)
|
|
VALUES ('delete', old.id, old.project, old.key, old.value);
|
|
INSERT INTO contexts_fts(rowid, project, key, value)
|
|
VALUES (new.id, new.project, new.key, new.value);
|
|
END;"
|
|
).map_err(|e| format!("Schema creation failed: {e}"))?;
|
|
|
|
drop(conn);
|
|
|
|
// Re-open as read-only for normal operation
|
|
let ro_conn = Connection::open_with_flags(
|
|
&db_path,
|
|
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
).map_err(|e| format!("Failed to reopen database: {e}"))?;
|
|
|
|
let mut lock = self.conn.lock().unwrap();
|
|
*lock = Some(ro_conn);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_projects(&self) -> Result<Vec<CtxProject>, String> {
|
|
let lock = self.conn.lock().unwrap();
|
|
let conn = lock.as_ref().ok_or("ctx database not found")?;
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT name, description, work_dir, created_at FROM sessions ORDER BY name")
|
|
.map_err(|e| format!("ctx query failed: {e}"))?;
|
|
|
|
let projects = stmt
|
|
.query_map([], |row| {
|
|
Ok(CtxProject {
|
|
name: row.get(0)?,
|
|
description: row.get(1)?,
|
|
work_dir: row.get(2)?,
|
|
created_at: row.get(3)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("ctx query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("ctx row read failed: {e}"))?;
|
|
|
|
Ok(projects)
|
|
}
|
|
|
|
pub fn get_context(&self, project: &str) -> Result<Vec<CtxEntry>, String> {
|
|
let lock = self.conn.lock().unwrap();
|
|
let conn = lock.as_ref().ok_or("ctx database not found")?;
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT project, key, value, updated_at FROM contexts WHERE project = ?1 ORDER BY key")
|
|
.map_err(|e| format!("ctx query failed: {e}"))?;
|
|
|
|
let entries = stmt
|
|
.query_map(params![project], |row| {
|
|
Ok(CtxEntry {
|
|
project: row.get(0)?,
|
|
key: row.get(1)?,
|
|
value: row.get(2)?,
|
|
updated_at: row.get(3)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("ctx query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("ctx row read failed: {e}"))?;
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
pub fn get_shared(&self) -> Result<Vec<CtxEntry>, String> {
|
|
let lock = self.conn.lock().unwrap();
|
|
let conn = lock.as_ref().ok_or("ctx database not found")?;
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT key, value, updated_at FROM shared ORDER BY key")
|
|
.map_err(|e| format!("ctx query failed: {e}"))?;
|
|
|
|
let entries = stmt
|
|
.query_map([], |row| {
|
|
Ok(CtxEntry {
|
|
project: "shared".to_string(),
|
|
key: row.get(0)?,
|
|
value: row.get(1)?,
|
|
updated_at: row.get(2)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("ctx query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("ctx row read failed: {e}"))?;
|
|
|
|
Ok(entries)
|
|
}
|
|
|
|
pub fn get_summaries(&self, project: &str, limit: i64) -> Result<Vec<CtxSummary>, String> {
|
|
let lock = self.conn.lock().unwrap();
|
|
let conn = lock.as_ref().ok_or("ctx database not found")?;
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT project, summary, created_at FROM summaries WHERE project = ?1 ORDER BY created_at DESC LIMIT ?2")
|
|
.map_err(|e| format!("ctx query failed: {e}"))?;
|
|
|
|
let summaries = stmt
|
|
.query_map(params![project, limit], |row| {
|
|
Ok(CtxSummary {
|
|
project: row.get(0)?,
|
|
summary: row.get(1)?,
|
|
created_at: row.get(2)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("ctx query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("ctx row read failed: {e}"))?;
|
|
|
|
Ok(summaries)
|
|
}
|
|
|
|
pub fn search(&self, query: &str) -> Result<Vec<CtxEntry>, String> {
|
|
let lock = self.conn.lock().unwrap();
|
|
let conn = lock.as_ref().ok_or("ctx database not found")?;
|
|
|
|
let mut stmt = conn
|
|
.prepare("SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?1 LIMIT 50")
|
|
.map_err(|e| format!("ctx search failed: {e}"))?;
|
|
|
|
let entries = stmt
|
|
.query_map(params![query], |row| {
|
|
Ok(CtxEntry {
|
|
project: row.get(0)?,
|
|
key: row.get(1)?,
|
|
value: row.get(2)?,
|
|
updated_at: String::new(),
|
|
})
|
|
})
|
|
.map_err(|e| format!("ctx search failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("ctx row read failed: {e}"))?;
|
|
|
|
Ok(entries)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Create a CtxDb with conn set to None, simulating a missing database.
|
|
fn make_missing_db() -> CtxDb {
|
|
CtxDb { conn: Mutex::new(None) }
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_does_not_panic() {
|
|
// CtxDb::new() should never panic even if ~/.claude-context/context.db
|
|
// doesn't exist — it just stores None for the connection.
|
|
let _db = CtxDb::new();
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_projects_missing_db_returns_error() {
|
|
let db = make_missing_db();
|
|
let result = db.list_projects();
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "ctx database not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_context_missing_db_returns_error() {
|
|
let db = make_missing_db();
|
|
let result = db.get_context("any-project");
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "ctx database not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_shared_missing_db_returns_error() {
|
|
let db = make_missing_db();
|
|
let result = db.get_shared();
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "ctx database not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_summaries_missing_db_returns_error() {
|
|
let db = make_missing_db();
|
|
let result = db.get_summaries("any-project", 10);
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "ctx database not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_missing_db_returns_error() {
|
|
let db = make_missing_db();
|
|
let result = db.search("anything");
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "ctx database not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_empty_query_missing_db_returns_error() {
|
|
let db = make_missing_db();
|
|
let result = db.search("");
|
|
assert!(result.is_err());
|
|
assert_eq!(result.unwrap_err(), "ctx database not found");
|
|
}
|
|
}
|