use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use std::collections::HashSet;
use std::path::PathBuf;
use uuid::Uuid;
use super::models::{
Annotation, Machine, Message, MessageContent, MessageRole, SearchResult, Session, SessionLink,
Summary, Tag,
};
fn parse_uuid(s: &str) -> rusqlite::Result<Uuid> {
Uuid::parse_str(s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
})
}
fn parse_datetime(s: &str) -> rusqlite::Result<DateTime<Utc>> {
chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
})
}
fn escape_fts5_query(query: &str) -> String {
query
.split_whitespace()
.map(|word| {
let escaped = word.replace('"', "\"\"");
format!("\"{escaped}\"")
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn default_db_path() -> Result<PathBuf> {
let config_dir = dirs::home_dir()
.context("Could not find home directory. Ensure your HOME environment variable is set.")?
.join(".lore");
std::fs::create_dir_all(&config_dir).with_context(|| {
format!(
"Failed to create Lore data directory at {}. Check directory permissions.",
config_dir.display()
)
})?;
Ok(config_dir.join("lore.db"))
}
pub struct Database {
conn: Connection,
}
impl Database {
pub fn open(path: &PathBuf) -> Result<Self> {
let conn = Connection::open(path)?;
let db = Self { conn };
db.migrate()?;
Ok(db)
}
pub fn open_default() -> Result<Self> {
let path = default_db_path()?;
Self::open(&path)
}
fn migrate(&self) -> Result<()> {
self.conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
tool TEXT NOT NULL,
tool_version TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
model TEXT,
working_directory TEXT NOT NULL,
git_branch TEXT,
source_path TEXT,
message_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
machine_id TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
parent_id TEXT,
idx INTEGER NOT NULL,
timestamp TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
git_branch TEXT,
cwd TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS session_links (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
link_type TEXT NOT NULL,
commit_sha TEXT,
branch TEXT,
remote TEXT,
created_at TEXT NOT NULL,
created_by TEXT NOT NULL,
confidence REAL,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
remote_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_session_at TEXT
);
CREATE TABLE IF NOT EXISTS annotations (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
label TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id),
UNIQUE(session_id, label)
);
CREATE TABLE IF NOT EXISTS summaries (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
generated_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS machines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
CREATE INDEX IF NOT EXISTS idx_sessions_working_directory ON sessions(working_directory);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_session_links_session_id ON session_links(session_id);
CREATE INDEX IF NOT EXISTS idx_session_links_commit_sha ON session_links(commit_sha);
CREATE INDEX IF NOT EXISTS idx_annotations_session_id ON annotations(session_id);
CREATE INDEX IF NOT EXISTS idx_tags_session_id ON tags(session_id);
CREATE INDEX IF NOT EXISTS idx_tags_label ON tags(label);
"#,
)?;
self.conn.execute_batch(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id,
text_content,
tokenize='porter unicode61'
);
"#,
)?;
self.conn.execute_batch(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
session_id,
tool,
working_directory,
git_branch,
tokenize='porter unicode61'
);
"#,
)?;
self.migrate_add_machine_id()?;
self.migrate_add_synced_at()?;
Ok(())
}
fn migrate_add_machine_id(&self) -> Result<()> {
let columns: Vec<String> = self
.conn
.prepare("PRAGMA table_info(sessions)")?
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
if !columns.iter().any(|c| c == "machine_id") {
self.conn
.execute("ALTER TABLE sessions ADD COLUMN machine_id TEXT", [])?;
}
if let Some(machine_uuid) = super::get_machine_id() {
self.conn.execute(
"UPDATE sessions SET machine_id = ?1 WHERE machine_id IS NULL",
[&machine_uuid],
)?;
if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok()) {
self.conn.execute(
"UPDATE sessions SET machine_id = ?1 WHERE machine_id = ?2",
[&machine_uuid, &hostname],
)?;
}
}
Ok(())
}
fn migrate_add_synced_at(&self) -> Result<()> {
let columns: Vec<String> = self
.conn
.prepare("PRAGMA table_info(sessions)")?
.query_map([], |row| row.get::<_, String>(1))?
.collect::<Result<Vec<_>, _>>()?;
if !columns.iter().any(|c| c == "synced_at") {
self.conn
.execute("ALTER TABLE sessions ADD COLUMN synced_at TEXT", [])?;
}
Ok(())
}
pub fn insert_session(&self, session: &Session) -> Result<()> {
let rows_changed = self.conn.execute(
r#"
INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(id) DO UPDATE SET
ended_at = ?5,
message_count = ?10,
synced_at = CASE
WHEN message_count != ?10 THEN NULL
WHEN (ended_at IS NULL AND ?5 IS NOT NULL) THEN NULL
WHEN (ended_at IS NOT NULL AND ?5 IS NULL) THEN NULL
WHEN ended_at != ?5 THEN NULL
ELSE synced_at
END
"#,
params![
session.id.to_string(),
session.tool,
session.tool_version,
session.started_at.to_rfc3339(),
session.ended_at.map(|t| t.to_rfc3339()),
session.model,
session.working_directory,
session.git_branch,
session.source_path,
session.message_count,
session.machine_id,
],
)?;
if rows_changed > 0 {
let fts_count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
params![session.id.to_string()],
|row| row.get(0),
)?;
if fts_count == 0 {
self.conn.execute(
"INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
params![
session.id.to_string(),
session.tool,
session.working_directory,
session.git_branch.as_deref().unwrap_or(""),
],
)?;
}
}
Ok(())
}
pub fn get_session(&self, id: &Uuid) -> Result<Option<Session>> {
self.conn
.query_row(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE id = ?1",
params![id.to_string()],
Self::row_to_session,
)
.optional()
.context("Failed to get session")
}
pub fn list_sessions(&self, limit: usize, working_dir: Option<&str>) -> Result<Vec<Session>> {
let mut stmt = if working_dir.is_some() {
self.conn.prepare(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE working_directory LIKE ?1
ORDER BY started_at DESC
LIMIT ?2"
)?
} else {
self.conn.prepare(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
ORDER BY started_at DESC
LIMIT ?1"
)?
};
let rows = if let Some(wd) = working_dir {
stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
} else {
stmt.query_map(params![limit], Self::row_to_session)?
};
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to list sessions")
}
pub fn list_ended_sessions(
&self,
limit: usize,
working_dir: Option<&str>,
) -> Result<Vec<Session>> {
let mut stmt = if working_dir.is_some() {
self.conn.prepare(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE ended_at IS NOT NULL
AND working_directory LIKE ?1
ORDER BY started_at DESC
LIMIT ?2",
)?
} else {
self.conn.prepare(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE ended_at IS NOT NULL
ORDER BY started_at DESC
LIMIT ?1",
)?
};
let rows = if let Some(wd) = working_dir {
stmt.query_map(params![format!("{}%", wd), limit], Self::row_to_session)?
} else {
stmt.query_map(params![limit], Self::row_to_session)?
};
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to list ended sessions")
}
pub fn session_exists_by_source(&self, source_path: &str) -> Result<bool> {
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE source_path = ?1",
params![source_path],
|row| row.get(0),
)?;
Ok(count > 0)
}
pub fn get_session_by_source(&self, source_path: &str) -> Result<Option<Session>> {
self.conn
.query_row(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id FROM sessions WHERE source_path = ?1",
params![source_path],
Self::row_to_session,
)
.optional()
.context("Failed to get session by source path")
}
pub fn find_session_by_id_prefix(&self, prefix: &str) -> Result<Option<Session>> {
if let Ok(uuid) = Uuid::parse_str(prefix) {
return self.get_session(&uuid);
}
let pattern = format!("{prefix}%");
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE id LIKE ?1",
params![pattern],
|row| row.get(0),
)?;
match count {
0 => Ok(None),
1 => {
self.conn
.query_row(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE id LIKE ?1",
params![pattern],
Self::row_to_session,
)
.optional()
.context("Failed to find session by prefix")
}
n => {
anyhow::bail!(
"Ambiguous session ID prefix '{prefix}' matches {n} sessions. Use a longer prefix."
)
}
}
}
pub fn update_session_branch(&self, session_id: Uuid, new_branch: &str) -> Result<usize> {
let rows_changed = self.conn.execute(
"UPDATE sessions SET git_branch = ?1 WHERE id = ?2",
params![new_branch, session_id.to_string()],
)?;
if rows_changed > 0 {
self.conn.execute(
"UPDATE sessions_fts SET git_branch = ?1 WHERE session_id = ?2",
params![new_branch, session_id.to_string()],
)?;
}
Ok(rows_changed)
}
fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<Session> {
let ended_at_str: Option<String> = row.get(4)?;
let ended_at = match ended_at_str {
Some(s) => Some(parse_datetime(&s)?),
None => None,
};
Ok(Session {
id: parse_uuid(&row.get::<_, String>(0)?)?,
tool: row.get(1)?,
tool_version: row.get(2)?,
started_at: parse_datetime(&row.get::<_, String>(3)?)?,
ended_at,
model: row.get(5)?,
working_directory: row.get(6)?,
git_branch: row.get(7)?,
source_path: row.get(8)?,
message_count: row.get(9)?,
machine_id: row.get(10)?,
})
}
pub fn insert_message(&self, message: &Message) -> Result<()> {
let content_json = serde_json::to_string(&message.content)?;
let rows_changed = self.conn.execute(
r#"
INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(id) DO NOTHING
"#,
params![
message.id.to_string(),
message.session_id.to_string(),
message.parent_id.map(|u| u.to_string()),
message.index,
message.timestamp.to_rfc3339(),
message.role.to_string(),
content_json,
message.model,
message.git_branch,
message.cwd,
],
)?;
if rows_changed > 0 {
let text_content = message.content.text();
if !text_content.is_empty() {
self.conn.execute(
"INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
params![message.id.to_string(), text_content],
)?;
}
}
Ok(())
}
pub fn import_session_with_messages(
&mut self,
session: &Session,
messages: &[Message],
synced_at: Option<DateTime<Utc>>,
) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute(
r#"
INSERT INTO sessions (id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id, synced_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
ON CONFLICT(id) DO UPDATE SET
ended_at = ?5,
message_count = ?10,
synced_at = COALESCE(?12, synced_at)
"#,
params![
session.id.to_string(),
session.tool,
session.tool_version,
session.started_at.to_rfc3339(),
session.ended_at.map(|t| t.to_rfc3339()),
session.model,
session.working_directory,
session.git_branch,
session.source_path,
session.message_count,
session.machine_id,
synced_at.map(|t| t.to_rfc3339()),
],
)?;
let fts_count: i32 = tx.query_row(
"SELECT COUNT(*) FROM sessions_fts WHERE session_id = ?1",
params![session.id.to_string()],
|row| row.get(0),
)?;
if fts_count == 0 {
tx.execute(
"INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
params![
session.id.to_string(),
session.tool,
session.working_directory,
session.git_branch.as_deref().unwrap_or(""),
],
)?;
}
for message in messages {
let content_json = serde_json::to_string(&message.content)?;
let rows_changed = tx.execute(
r#"
INSERT INTO messages (id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(id) DO NOTHING
"#,
params![
message.id.to_string(),
message.session_id.to_string(),
message.parent_id.map(|u| u.to_string()),
message.index,
message.timestamp.to_rfc3339(),
message.role.to_string(),
content_json,
message.model,
message.git_branch,
message.cwd,
],
)?;
if rows_changed > 0 {
let text_content = message.content.text();
if !text_content.is_empty() {
tx.execute(
"INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
params![message.id.to_string(), text_content],
)?;
}
}
}
tx.commit()?;
Ok(())
}
pub fn get_messages(&self, session_id: &Uuid) -> Result<Vec<Message>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
FROM messages
WHERE session_id = ?1
ORDER BY idx"
)?;
let rows = stmt.query_map(params![session_id.to_string()], |row| {
let role_str: String = row.get(5)?;
let content_str: String = row.get(6)?;
let parent_id_str: Option<String> = row.get(2)?;
let parent_id = match parent_id_str {
Some(s) => Some(parse_uuid(&s)?),
None => None,
};
Ok(Message {
id: parse_uuid(&row.get::<_, String>(0)?)?,
session_id: parse_uuid(&row.get::<_, String>(1)?)?,
parent_id,
index: row.get(3)?,
timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
role: match role_str.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" => MessageRole::System,
_ => MessageRole::User,
},
content: serde_json::from_str(&content_str)
.unwrap_or(MessageContent::Text(content_str)),
model: row.get(7)?,
git_branch: row.get(8)?,
cwd: row.get(9)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get messages")
}
pub fn get_session_branch_history(&self, session_id: Uuid) -> Result<Vec<String>> {
let mut stmt = self
.conn
.prepare("SELECT git_branch FROM messages WHERE session_id = ?1 ORDER BY idx")?;
let rows = stmt.query_map(params![session_id.to_string()], |row| {
let branch: Option<String> = row.get(0)?;
Ok(branch)
})?;
let mut branches: Vec<String> = Vec::new();
for row in rows {
if let Some(branch) = row? {
if branches.last() != Some(&branch) {
branches.push(branch);
}
}
}
Ok(branches)
}
pub fn insert_link(&self, link: &SessionLink) -> Result<()> {
self.conn.execute(
r#"
INSERT INTO session_links (id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
"#,
params![
link.id.to_string(),
link.session_id.to_string(),
format!("{:?}", link.link_type).to_lowercase(),
link.commit_sha,
link.branch,
link.remote,
link.created_at.to_rfc3339(),
format!("{:?}", link.created_by).to_lowercase(),
link.confidence,
],
)?;
Ok(())
}
pub fn get_links_by_commit(&self, commit_sha: &str) -> Result<Vec<SessionLink>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
FROM session_links
WHERE commit_sha LIKE ?1"
)?;
let pattern = format!("{commit_sha}%");
let rows = stmt.query_map(params![pattern], Self::row_to_link)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get links")
}
pub fn get_links_by_session(&self, session_id: &Uuid) -> Result<Vec<SessionLink>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, link_type, commit_sha, branch, remote, created_at, created_by, confidence
FROM session_links
WHERE session_id = ?1"
)?;
let rows = stmt.query_map(params![session_id.to_string()], Self::row_to_link)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get links")
}
fn row_to_link(row: &rusqlite::Row) -> rusqlite::Result<SessionLink> {
use super::models::{LinkCreator, LinkType};
let link_type_str: String = row.get(2)?;
let created_by_str: String = row.get(7)?;
Ok(SessionLink {
id: parse_uuid(&row.get::<_, String>(0)?)?,
session_id: parse_uuid(&row.get::<_, String>(1)?)?,
link_type: match link_type_str.as_str() {
"commit" => LinkType::Commit,
"branch" => LinkType::Branch,
"pr" => LinkType::Pr,
_ => LinkType::Manual,
},
commit_sha: row.get(3)?,
branch: row.get(4)?,
remote: row.get(5)?,
created_at: parse_datetime(&row.get::<_, String>(6)?)?,
created_by: match created_by_str.as_str() {
"auto" => LinkCreator::Auto,
_ => LinkCreator::User,
},
confidence: row.get(8)?,
})
}
#[allow(dead_code)]
pub fn delete_link(&self, link_id: &Uuid) -> Result<bool> {
let rows_affected = self.conn.execute(
"DELETE FROM session_links WHERE id = ?1",
params![link_id.to_string()],
)?;
Ok(rows_affected > 0)
}
pub fn delete_links_by_session(&self, session_id: &Uuid) -> Result<usize> {
let rows_affected = self.conn.execute(
"DELETE FROM session_links WHERE session_id = ?1",
params![session_id.to_string()],
)?;
Ok(rows_affected)
}
pub fn delete_link_by_session_and_commit(
&self,
session_id: &Uuid,
commit_sha: &str,
) -> Result<bool> {
let pattern = format!("{commit_sha}%");
let rows_affected = self.conn.execute(
"DELETE FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
params![session_id.to_string(), pattern],
)?;
Ok(rows_affected > 0)
}
#[allow(dead_code)]
pub fn search_messages(
&self,
query: &str,
limit: usize,
working_dir: Option<&str>,
since: Option<chrono::DateTime<chrono::Utc>>,
role: Option<&str>,
) -> Result<Vec<SearchResult>> {
use super::models::SearchOptions;
let options = SearchOptions {
query: query.to_string(),
limit,
repo: working_dir.map(|s| s.to_string()),
since,
role: role.map(|s| s.to_string()),
..Default::default()
};
self.search_with_options(&options)
}
pub fn search_with_options(
&self,
options: &super::models::SearchOptions,
) -> Result<Vec<SearchResult>> {
let escaped_query = escape_fts5_query(&options.query);
let mut sql = String::from(
r#"
SELECT
m.session_id,
m.id as message_id,
m.role,
snippet(messages_fts, 1, '**', '**', '...', 32) as snippet,
m.timestamp,
s.working_directory,
s.tool,
s.git_branch,
s.message_count,
s.started_at,
m.idx as message_index
FROM messages_fts fts
JOIN messages m ON fts.message_id = m.id
JOIN sessions s ON m.session_id = s.id
WHERE messages_fts MATCH ?1
"#,
);
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(escaped_query.clone())];
let mut param_idx = 2;
if options.repo.is_some() {
sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
param_idx += 1;
}
if options.tool.is_some() {
sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
param_idx += 1;
}
if options.since.is_some() {
sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
param_idx += 1;
}
if options.until.is_some() {
sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
param_idx += 1;
}
if options.project.is_some() {
sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
param_idx += 1;
}
if options.branch.is_some() {
sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
param_idx += 1;
}
if options.role.is_some() {
sql.push_str(&format!(" AND m.role = ?{param_idx}"));
param_idx += 1;
}
if let Some(ref wd) = options.repo {
params_vec.push(Box::new(format!("{wd}%")));
}
if let Some(ref tool) = options.tool {
params_vec.push(Box::new(tool.clone()));
}
if let Some(ts) = options.since {
params_vec.push(Box::new(ts.to_rfc3339()));
}
if let Some(ts) = options.until {
params_vec.push(Box::new(ts.to_rfc3339()));
}
if let Some(ref project) = options.project {
params_vec.push(Box::new(format!("%{project}%")));
}
if let Some(ref branch) = options.branch {
params_vec.push(Box::new(format!("%{branch}%")));
}
if let Some(ref role) = options.role {
params_vec.push(Box::new(role.clone()));
}
let include_metadata_search = options.role.is_none();
let metadata_query_pattern = format!("%{}%", options.query);
if include_metadata_search {
let meta_param1 = param_idx;
let meta_param2 = param_idx + 1;
let meta_param3 = param_idx + 2;
param_idx += 3;
sql.push_str(&format!(
r#"
UNION
SELECT
s.id as session_id,
(SELECT id FROM messages WHERE session_id = s.id ORDER BY idx LIMIT 1) as message_id,
'user' as role,
substr(s.tool || ' session in ' || s.working_directory || COALESCE(' on branch ' || s.git_branch, ''), 1, 100) as snippet,
s.started_at as timestamp,
s.working_directory,
s.tool,
s.git_branch,
s.message_count,
s.started_at,
0 as message_index
FROM sessions s
WHERE (
s.tool LIKE ?{meta_param1}
OR s.working_directory LIKE ?{meta_param2}
OR s.git_branch LIKE ?{meta_param3}
)
"#
));
params_vec.push(Box::new(metadata_query_pattern.clone()));
params_vec.push(Box::new(metadata_query_pattern.clone()));
params_vec.push(Box::new(metadata_query_pattern));
if let Some(repo) = &options.repo {
sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
params_vec.push(Box::new(format!("{}%", repo)));
param_idx += 1;
}
if let Some(tool) = &options.tool {
sql.push_str(&format!(" AND LOWER(s.tool) = LOWER(?{param_idx})"));
params_vec.push(Box::new(tool.clone()));
param_idx += 1;
}
if let Some(since) = options.since {
sql.push_str(&format!(" AND s.started_at >= ?{param_idx}"));
params_vec.push(Box::new(since.to_rfc3339()));
param_idx += 1;
}
if let Some(until) = options.until {
sql.push_str(&format!(" AND s.started_at <= ?{param_idx}"));
params_vec.push(Box::new(until.to_rfc3339()));
param_idx += 1;
}
if let Some(project) = &options.project {
sql.push_str(&format!(" AND s.working_directory LIKE ?{param_idx}"));
params_vec.push(Box::new(format!("%{}%", project)));
param_idx += 1;
}
if let Some(branch) = &options.branch {
sql.push_str(&format!(" AND s.git_branch LIKE ?{param_idx}"));
params_vec.push(Box::new(format!("%{}%", branch)));
param_idx += 1;
}
}
sql.push_str(&format!(" ORDER BY timestamp DESC LIMIT ?{param_idx}"));
params_vec.push(Box::new(options.limit as i64));
let mut stmt = self.conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
let role_str: String = row.get(2)?;
let git_branch: Option<String> = row.get(7)?;
let started_at_str: Option<String> = row.get(9)?;
Ok(SearchResult {
session_id: parse_uuid(&row.get::<_, String>(0)?)?,
message_id: parse_uuid(&row.get::<_, String>(1)?)?,
role: match role_str.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" => MessageRole::System,
_ => MessageRole::User,
},
snippet: row.get(3)?,
timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
working_directory: row.get(5)?,
tool: row.get(6)?,
git_branch,
session_message_count: row.get(8)?,
session_started_at: started_at_str.map(|s| parse_datetime(&s)).transpose()?,
message_index: row.get(10)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to search messages")
}
pub fn get_context_messages(
&self,
session_id: &Uuid,
message_index: i32,
context_count: usize,
) -> Result<(Vec<Message>, Vec<Message>)> {
let mut before_stmt = self.conn.prepare(
"SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
FROM messages
WHERE session_id = ?1 AND idx < ?2
ORDER BY idx DESC
LIMIT ?3",
)?;
let before_rows = before_stmt.query_map(
params![session_id.to_string(), message_index, context_count as i64],
Self::row_to_message,
)?;
let mut before: Vec<Message> = before_rows
.collect::<Result<Vec<_>, _>>()
.context("Failed to get before messages")?;
before.reverse();
let mut after_stmt = self.conn.prepare(
"SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
FROM messages
WHERE session_id = ?1 AND idx > ?2
ORDER BY idx ASC
LIMIT ?3",
)?;
let after_rows = after_stmt.query_map(
params![session_id.to_string(), message_index, context_count as i64],
Self::row_to_message,
)?;
let after: Vec<Message> = after_rows
.collect::<Result<Vec<_>, _>>()
.context("Failed to get after messages")?;
Ok((before, after))
}
#[allow(dead_code)]
pub fn get_message_by_index(&self, session_id: &Uuid, index: i32) -> Result<Option<Message>> {
self.conn
.query_row(
"SELECT id, session_id, parent_id, idx, timestamp, role, content, model, git_branch, cwd
FROM messages
WHERE session_id = ?1 AND idx = ?2",
params![session_id.to_string(), index],
Self::row_to_message,
)
.optional()
.context("Failed to get message by index")
}
fn row_to_message(row: &rusqlite::Row) -> rusqlite::Result<Message> {
let role_str: String = row.get(5)?;
let content_str: String = row.get(6)?;
let parent_id_str: Option<String> = row.get(2)?;
let parent_id = match parent_id_str {
Some(s) => Some(parse_uuid(&s)?),
None => None,
};
Ok(Message {
id: parse_uuid(&row.get::<_, String>(0)?)?,
session_id: parse_uuid(&row.get::<_, String>(1)?)?,
parent_id,
index: row.get(3)?,
timestamp: parse_datetime(&row.get::<_, String>(4)?)?,
role: match role_str.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" => MessageRole::System,
_ => MessageRole::User,
},
content: serde_json::from_str(&content_str)
.unwrap_or(MessageContent::Text(content_str)),
model: row.get(7)?,
git_branch: row.get(8)?,
cwd: row.get(9)?,
})
}
pub fn rebuild_search_index(&self) -> Result<usize> {
self.conn.execute("DELETE FROM messages_fts", [])?;
self.conn.execute("DELETE FROM sessions_fts", [])?;
let mut msg_stmt = self.conn.prepare("SELECT id, content FROM messages")?;
let rows = msg_stmt.query_map([], |row| {
let id: String = row.get(0)?;
let content_json: String = row.get(1)?;
Ok((id, content_json))
})?;
let mut count = 0;
for row in rows {
let (id, content_json) = row?;
let content: MessageContent = serde_json::from_str(&content_json)
.unwrap_or(MessageContent::Text(content_json.clone()));
let text_content = content.text();
if !text_content.is_empty() {
self.conn.execute(
"INSERT INTO messages_fts (message_id, text_content) VALUES (?1, ?2)",
params![id, text_content],
)?;
count += 1;
}
}
let mut session_stmt = self
.conn
.prepare("SELECT id, tool, working_directory, git_branch FROM sessions")?;
let session_rows = session_stmt.query_map([], |row| {
let id: String = row.get(0)?;
let tool: String = row.get(1)?;
let working_directory: String = row.get(2)?;
let git_branch: Option<String> = row.get(3)?;
Ok((id, tool, working_directory, git_branch))
})?;
for row in session_rows {
let (id, tool, working_directory, git_branch) = row?;
self.conn.execute(
"INSERT INTO sessions_fts (session_id, tool, working_directory, git_branch) VALUES (?1, ?2, ?3, ?4)",
params![id, tool, working_directory, git_branch.unwrap_or_default()],
)?;
}
Ok(count)
}
pub fn search_index_needs_rebuild(&self) -> Result<bool> {
let message_count: i32 =
self.conn
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
let msg_fts_count: i32 =
self.conn
.query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
let session_count: i32 =
self.conn
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
let session_fts_count: i32 =
self.conn
.query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
Ok((message_count > 0 && msg_fts_count == 0)
|| (session_count > 0 && session_fts_count == 0))
}
pub fn get_unsynced_sessions(&self) -> Result<Vec<Session>> {
let mut stmt = self.conn.prepare(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE synced_at IS NULL
ORDER BY started_at ASC"
)?;
let rows = stmt.query_map([], Self::row_to_session)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get unsynced sessions")
}
pub fn unsynced_session_count(&self) -> Result<i32> {
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE synced_at IS NULL",
[],
|row| row.get(0),
)?;
Ok(count)
}
pub fn mark_sessions_synced(
&self,
session_ids: &[Uuid],
synced_at: DateTime<Utc>,
) -> Result<usize> {
if session_ids.is_empty() {
return Ok(0);
}
let synced_at_str = synced_at.to_rfc3339();
let mut total_updated = 0;
for id in session_ids {
let updated = self.conn.execute(
"UPDATE sessions SET synced_at = ?1 WHERE id = ?2",
params![synced_at_str, id.to_string()],
)?;
total_updated += updated;
}
Ok(total_updated)
}
pub fn last_sync_time(&self) -> Result<Option<DateTime<Utc>>> {
let result: Option<String> = self
.conn
.query_row(
"SELECT MAX(synced_at) FROM sessions WHERE synced_at IS NOT NULL",
[],
|row| row.get(0),
)
.optional()?
.flatten();
match result {
Some(s) => Ok(Some(parse_datetime(&s)?)),
None => Ok(None),
}
}
pub fn clear_sync_status(&self) -> Result<usize> {
let updated = self
.conn
.execute("UPDATE sessions SET synced_at = NULL", [])?;
Ok(updated)
}
pub fn clear_sync_status_for_sessions(&self, session_ids: &[Uuid]) -> Result<usize> {
if session_ids.is_empty() {
return Ok(0);
}
let mut total_updated = 0;
for id in session_ids {
let updated = self.conn.execute(
"UPDATE sessions SET synced_at = NULL WHERE id = ?1",
params![id.to_string()],
)?;
total_updated += updated;
}
Ok(total_updated)
}
pub fn session_count(&self) -> Result<i32> {
let count: i32 = self
.conn
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
Ok(count)
}
pub fn message_count(&self) -> Result<i32> {
let count: i32 = self
.conn
.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
Ok(count)
}
pub fn link_count(&self) -> Result<i32> {
let count: i32 = self
.conn
.query_row("SELECT COUNT(*) FROM session_links", [], |row| row.get(0))?;
Ok(count)
}
pub fn db_path(&self) -> Option<std::path::PathBuf> {
self.conn.path().map(std::path::PathBuf::from)
}
pub fn find_sessions_near_commit_time(
&self,
commit_time: chrono::DateTime<chrono::Utc>,
window_minutes: i64,
working_dir: Option<&str>,
) -> Result<Vec<Session>> {
let commit_time_str = commit_time.to_rfc3339();
let window = chrono::Duration::minutes(window_minutes);
let window_start = (commit_time - window).to_rfc3339();
let window_end = (commit_time + window).to_rfc3339();
let sql = if working_dir.is_some() {
r#"
SELECT id, tool, tool_version, started_at, ended_at, model,
working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE working_directory LIKE ?1
AND (
-- Session started before or during the window
(started_at <= ?3)
AND
-- Session ended after or during the window (or is still ongoing)
(ended_at IS NULL OR ended_at >= ?2)
)
ORDER BY
-- Order by how close the session end (or start) is to commit time
ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?4))
"#
} else {
r#"
SELECT id, tool, tool_version, started_at, ended_at, model,
working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE
-- Session started before or during the window
(started_at <= ?2)
AND
-- Session ended after or during the window (or is still ongoing)
(ended_at IS NULL OR ended_at >= ?1)
ORDER BY
-- Order by how close the session end (or start) is to commit time
ABS(julianday(COALESCE(ended_at, started_at)) - julianday(?3))
"#
};
let mut stmt = self.conn.prepare(sql)?;
let rows = if let Some(wd) = working_dir {
stmt.query_map(
params![format!("{wd}%"), window_start, window_end, commit_time_str],
Self::row_to_session,
)?
} else {
stmt.query_map(
params![window_start, window_end, commit_time_str],
Self::row_to_session,
)?
};
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to find sessions near commit time")
}
pub fn link_exists(&self, session_id: &Uuid, commit_sha: &str) -> Result<bool> {
let pattern = format!("{commit_sha}%");
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM session_links WHERE session_id = ?1 AND commit_sha LIKE ?2",
params![session_id.to_string(), pattern],
|row| row.get(0),
)?;
Ok(count > 0)
}
pub fn find_active_sessions_for_directory(
&self,
directory: &str,
recent_minutes: Option<i64>,
) -> Result<Vec<Session>> {
fn escape_like(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'|' => escaped.push_str("||"),
'%' => escaped.push_str("|%"),
'_' => escaped.push_str("|_"),
_ => escaped.push(ch),
}
}
escaped
}
let minutes = recent_minutes.unwrap_or(5);
let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339();
let separator = std::path::MAIN_SEPARATOR.to_string();
let mut normalized = directory
.trim_end_matches(std::path::MAIN_SEPARATOR)
.to_string();
if normalized.is_empty() {
normalized = separator.clone();
}
let trailing = if normalized == separator {
normalized.clone()
} else {
format!("{normalized}{separator}")
};
let like_pattern = format!("{}%", escape_like(&trailing));
let sql = r#"
SELECT id, tool, tool_version, started_at, ended_at, model,
working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE (working_directory = ?1
OR working_directory = ?2
OR working_directory LIKE ?3 ESCAPE '|')
AND (ended_at IS NULL OR ended_at >= ?4)
ORDER BY started_at DESC
"#;
let mut stmt = self.conn.prepare(sql)?;
let rows = stmt.query_map(
params![normalized, trailing, like_pattern, cutoff],
Self::row_to_session,
)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to find active sessions for directory")
}
pub fn delete_session(&self, session_id: &Uuid) -> Result<(usize, usize)> {
let session_id_str = session_id.to_string();
self.conn.execute(
"DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
params![session_id_str],
)?;
let messages_deleted = self.conn.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id_str],
)?;
let links_deleted = self.conn.execute(
"DELETE FROM session_links WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM annotations WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM tags WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM summaries WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM sessions_fts WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM sessions WHERE id = ?1",
params![session_id_str],
)?;
Ok((messages_deleted, links_deleted))
}
pub fn insert_annotation(&self, annotation: &Annotation) -> Result<()> {
self.conn.execute(
r#"
INSERT INTO annotations (id, session_id, content, created_at)
VALUES (?1, ?2, ?3, ?4)
"#,
params![
annotation.id.to_string(),
annotation.session_id.to_string(),
annotation.content,
annotation.created_at.to_rfc3339(),
],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn get_annotations(&self, session_id: &Uuid) -> Result<Vec<Annotation>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, content, created_at
FROM annotations
WHERE session_id = ?1
ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(params![session_id.to_string()], |row| {
Ok(Annotation {
id: parse_uuid(&row.get::<_, String>(0)?)?,
session_id: parse_uuid(&row.get::<_, String>(1)?)?,
content: row.get(2)?,
created_at: parse_datetime(&row.get::<_, String>(3)?)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get annotations")
}
#[allow(dead_code)]
pub fn delete_annotation(&self, annotation_id: &Uuid) -> Result<bool> {
let rows_affected = self.conn.execute(
"DELETE FROM annotations WHERE id = ?1",
params![annotation_id.to_string()],
)?;
Ok(rows_affected > 0)
}
#[allow(dead_code)]
pub fn delete_annotations_by_session(&self, session_id: &Uuid) -> Result<usize> {
let rows_affected = self.conn.execute(
"DELETE FROM annotations WHERE session_id = ?1",
params![session_id.to_string()],
)?;
Ok(rows_affected)
}
pub fn insert_tag(&self, tag: &Tag) -> Result<()> {
self.conn.execute(
r#"
INSERT INTO tags (id, session_id, label, created_at)
VALUES (?1, ?2, ?3, ?4)
"#,
params![
tag.id.to_string(),
tag.session_id.to_string(),
tag.label,
tag.created_at.to_rfc3339(),
],
)?;
Ok(())
}
pub fn get_tags(&self, session_id: &Uuid) -> Result<Vec<Tag>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, label, created_at
FROM tags
WHERE session_id = ?1
ORDER BY label ASC",
)?;
let rows = stmt.query_map(params![session_id.to_string()], |row| {
Ok(Tag {
id: parse_uuid(&row.get::<_, String>(0)?)?,
session_id: parse_uuid(&row.get::<_, String>(1)?)?,
label: row.get(2)?,
created_at: parse_datetime(&row.get::<_, String>(3)?)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get tags")
}
pub fn tag_exists(&self, session_id: &Uuid, label: &str) -> Result<bool> {
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM tags WHERE session_id = ?1 AND label = ?2",
params![session_id.to_string(), label],
|row| row.get(0),
)?;
Ok(count > 0)
}
pub fn delete_tag(&self, session_id: &Uuid, label: &str) -> Result<bool> {
let rows_affected = self.conn.execute(
"DELETE FROM tags WHERE session_id = ?1 AND label = ?2",
params![session_id.to_string(), label],
)?;
Ok(rows_affected > 0)
}
#[allow(dead_code)]
pub fn delete_tags_by_session(&self, session_id: &Uuid) -> Result<usize> {
let rows_affected = self.conn.execute(
"DELETE FROM tags WHERE session_id = ?1",
params![session_id.to_string()],
)?;
Ok(rows_affected)
}
pub fn list_sessions_with_tag(&self, label: &str, limit: usize) -> Result<Vec<Session>> {
let mut stmt = self.conn.prepare(
"SELECT s.id, s.tool, s.tool_version, s.started_at, s.ended_at, s.model,
s.working_directory, s.git_branch, s.source_path, s.message_count, s.machine_id
FROM sessions s
INNER JOIN tags t ON s.id = t.session_id
WHERE t.label = ?1
ORDER BY s.started_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![label, limit], Self::row_to_session)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to list sessions with tag")
}
pub fn insert_summary(&self, summary: &Summary) -> Result<()> {
self.conn.execute(
r#"
INSERT INTO summaries (id, session_id, content, generated_at)
VALUES (?1, ?2, ?3, ?4)
"#,
params![
summary.id.to_string(),
summary.session_id.to_string(),
summary.content,
summary.generated_at.to_rfc3339(),
],
)?;
Ok(())
}
pub fn get_summary(&self, session_id: &Uuid) -> Result<Option<Summary>> {
self.conn
.query_row(
"SELECT id, session_id, content, generated_at
FROM summaries
WHERE session_id = ?1",
params![session_id.to_string()],
|row| {
Ok(Summary {
id: parse_uuid(&row.get::<_, String>(0)?)?,
session_id: parse_uuid(&row.get::<_, String>(1)?)?,
content: row.get(2)?,
generated_at: parse_datetime(&row.get::<_, String>(3)?)?,
})
},
)
.optional()
.context("Failed to get summary")
}
pub fn get_sessions_with_summaries(&self, session_ids: &[Uuid]) -> Result<HashSet<Uuid>> {
if session_ids.is_empty() {
return Ok(HashSet::new());
}
let placeholders: Vec<&str> = session_ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT session_id FROM summaries WHERE session_id IN ({})",
placeholders.join(", ")
);
let params: Vec<Box<dyn rusqlite::types::ToSql>> = session_ids
.iter()
.map(|id| Box::new(id.to_string()) as Box<dyn rusqlite::types::ToSql>)
.collect();
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(param_refs.as_slice(), |row| {
let id_str: String = row.get(0)?;
parse_uuid(&id_str)
})?;
let mut result = HashSet::new();
for row in rows {
result.insert(row?);
}
Ok(result)
}
pub fn update_summary(&self, session_id: &Uuid, content: &str) -> Result<bool> {
let now = chrono::Utc::now().to_rfc3339();
let rows_affected = self.conn.execute(
"UPDATE summaries SET content = ?1, generated_at = ?2 WHERE session_id = ?3",
params![content, now, session_id.to_string()],
)?;
Ok(rows_affected > 0)
}
#[allow(dead_code)]
pub fn delete_summary(&self, session_id: &Uuid) -> Result<bool> {
let rows_affected = self.conn.execute(
"DELETE FROM summaries WHERE session_id = ?1",
params![session_id.to_string()],
)?;
Ok(rows_affected > 0)
}
pub fn upsert_machine(&self, machine: &Machine) -> Result<()> {
self.conn.execute(
r#"
INSERT INTO machines (id, name, created_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET
name = ?2
"#,
params![machine.id, machine.name, machine.created_at],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn get_machine(&self, id: &str) -> Result<Option<Machine>> {
self.conn
.query_row(
"SELECT id, name, created_at FROM machines WHERE id = ?1",
params![id],
|row| {
Ok(Machine {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
})
},
)
.optional()
.context("Failed to get machine")
}
#[allow(dead_code)]
pub fn get_machine_name(&self, id: &str) -> Result<String> {
if let Some(machine) = self.get_machine(id)? {
Ok(machine.name)
} else {
if id.len() > 8 {
Ok(id[..8].to_string())
} else {
Ok(id.to_string())
}
}
}
#[allow(dead_code)]
pub fn list_machines(&self) -> Result<Vec<Machine>> {
let mut stmt = self
.conn
.prepare("SELECT id, name, created_at FROM machines ORDER BY created_at ASC")?;
let rows = stmt.query_map([], |row| {
Ok(Machine {
id: row.get(0)?,
name: row.get(1)?,
created_at: row.get(2)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to list machines")
}
pub fn get_most_recent_session_for_directory(
&self,
working_dir: &str,
) -> Result<Option<Session>> {
self.conn
.query_row(
"SELECT id, tool, tool_version, started_at, ended_at, model,
working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE working_directory LIKE ?1
ORDER BY started_at DESC
LIMIT 1",
params![format!("{working_dir}%")],
Self::row_to_session,
)
.optional()
.context("Failed to get most recent session for directory")
}
pub fn vacuum(&self) -> Result<()> {
self.conn.execute("VACUUM", [])?;
Ok(())
}
pub fn file_size(&self) -> Result<Option<u64>> {
if let Some(path) = self.db_path() {
let metadata = std::fs::metadata(&path)?;
Ok(Some(metadata.len()))
} else {
Ok(None)
}
}
pub fn delete_sessions_older_than(&self, before: DateTime<Utc>) -> Result<usize> {
let before_str = before.to_rfc3339();
let mut stmt = self
.conn
.prepare("SELECT id FROM sessions WHERE started_at < ?1")?;
let session_ids: Vec<String> = stmt
.query_map(params![before_str], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
if session_ids.is_empty() {
return Ok(0);
}
let count = session_ids.len();
for session_id_str in &session_ids {
self.conn.execute(
"DELETE FROM messages_fts WHERE message_id IN (SELECT id FROM messages WHERE session_id = ?1)",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM session_links WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM annotations WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM tags WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM summaries WHERE session_id = ?1",
params![session_id_str],
)?;
self.conn.execute(
"DELETE FROM sessions_fts WHERE session_id = ?1",
params![session_id_str],
)?;
}
self.conn.execute(
"DELETE FROM sessions WHERE started_at < ?1",
params![before_str],
)?;
Ok(count)
}
pub fn count_sessions_older_than(&self, before: DateTime<Utc>) -> Result<i32> {
let before_str = before.to_rfc3339();
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE started_at < ?1",
params![before_str],
|row| row.get(0),
)?;
Ok(count)
}
pub fn get_sessions_older_than(&self, before: DateTime<Utc>) -> Result<Vec<Session>> {
let before_str = before.to_rfc3339();
let mut stmt = self.conn.prepare(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions
WHERE started_at < ?1
ORDER BY started_at ASC",
)?;
let rows = stmt.query_map(params![before_str], Self::row_to_session)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to get sessions older than cutoff")
}
pub fn stats(&self) -> Result<DatabaseStats> {
let session_count = self.session_count()?;
let message_count = self.message_count()?;
let link_count = self.link_count()?;
let oldest: Option<String> = self
.conn
.query_row("SELECT MIN(started_at) FROM sessions", [], |row| row.get(0))
.optional()?
.flatten();
let newest: Option<String> = self
.conn
.query_row("SELECT MAX(started_at) FROM sessions", [], |row| row.get(0))
.optional()?
.flatten();
let oldest_session = oldest
.map(|s| parse_datetime(&s))
.transpose()
.unwrap_or(None);
let newest_session = newest
.map(|s| parse_datetime(&s))
.transpose()
.unwrap_or(None);
let mut stmt = self
.conn
.prepare("SELECT tool, COUNT(*) FROM sessions GROUP BY tool ORDER BY COUNT(*) DESC")?;
let sessions_by_tool: Vec<(String, i32)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(DatabaseStats {
session_count,
message_count,
link_count,
oldest_session,
newest_session,
sessions_by_tool,
})
}
pub fn sessions_in_date_range(
&self,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
working_dir: Option<&str>,
) -> Result<Vec<Session>> {
let mut conditions = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(since) = since {
conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
param_values.push(Box::new(since.to_rfc3339()));
}
if let Some(until) = until {
conditions.push(format!("started_at <= ?{}", param_values.len() + 1));
param_values.push(Box::new(until.to_rfc3339()));
}
if let Some(wd) = working_dir {
conditions.push(format!(
"working_directory LIKE ?{}",
param_values.len() + 1
));
param_values.push(Box::new(format!("{}%", wd)));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT id, tool, tool_version, started_at, ended_at, model, working_directory, git_branch, source_path, message_count, machine_id
FROM sessions{}
ORDER BY started_at DESC",
where_clause
);
let mut stmt = self.conn.prepare(&sql)?;
let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
let rows = stmt.query_map(params, Self::row_to_session)?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to query sessions in date range")
}
pub fn average_session_duration_minutes(
&self,
since: Option<DateTime<Utc>>,
working_dir: Option<&str>,
) -> Result<Option<f64>> {
let mut conditions = vec!["ended_at IS NOT NULL".to_string()];
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(since) = since {
conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
param_values.push(Box::new(since.to_rfc3339()));
}
if let Some(wd) = working_dir {
conditions.push(format!(
"working_directory LIKE ?{}",
param_values.len() + 1
));
param_values.push(Box::new(format!("{}%", wd)));
}
let where_clause = format!(" WHERE {}", conditions.join(" AND "));
let sql = format!(
"SELECT AVG((julianday(ended_at) - julianday(started_at)) * 24 * 60) FROM sessions{}",
where_clause
);
let avg: Option<f64> = self
.conn
.query_row(
&sql,
rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
|row| row.get(0),
)
.optional()?
.flatten();
Ok(avg)
}
pub fn sessions_by_tool_in_range(
&self,
since: Option<DateTime<Utc>>,
working_dir: Option<&str>,
) -> Result<Vec<(String, i32)>> {
let mut conditions = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(since) = since {
conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
param_values.push(Box::new(since.to_rfc3339()));
}
if let Some(wd) = working_dir {
conditions.push(format!(
"working_directory LIKE ?{}",
param_values.len() + 1
));
param_values.push(Box::new(format!("{}%", wd)));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT tool, COUNT(*) FROM sessions{} GROUP BY tool ORDER BY COUNT(*) DESC",
where_clause
);
let mut stmt = self.conn.prepare(&sql)?;
let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to query sessions by tool")
}
pub fn sessions_by_weekday(
&self,
since: Option<DateTime<Utc>>,
working_dir: Option<&str>,
) -> Result<Vec<(i32, i32)>> {
let mut conditions = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(since) = since {
conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
param_values.push(Box::new(since.to_rfc3339()));
}
if let Some(wd) = working_dir {
conditions.push(format!(
"working_directory LIKE ?{}",
param_values.len() + 1
));
param_values.push(Box::new(format!("{}%", wd)));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT CAST(strftime('%w', started_at) AS INTEGER), COUNT(*) FROM sessions{} GROUP BY strftime('%w', started_at) ORDER BY strftime('%w', started_at)",
where_clause
);
let mut stmt = self.conn.prepare(&sql)?;
let params = rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref()));
let rows = stmt.query_map(params, |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect::<Result<Vec<_>, _>>()
.context("Failed to query sessions by weekday")
}
pub fn average_message_count(
&self,
since: Option<DateTime<Utc>>,
working_dir: Option<&str>,
) -> Result<Option<f64>> {
let mut conditions = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(since) = since {
conditions.push(format!("started_at >= ?{}", param_values.len() + 1));
param_values.push(Box::new(since.to_rfc3339()));
}
if let Some(wd) = working_dir {
conditions.push(format!(
"working_directory LIKE ?{}",
param_values.len() + 1
));
param_values.push(Box::new(format!("{}%", wd)));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!("SELECT AVG(message_count) FROM sessions{}", where_clause);
let avg: Option<f64> = self
.conn
.query_row(
&sql,
rusqlite::params_from_iter(param_values.iter().map(|p| p.as_ref())),
|row| row.get(0),
)
.optional()?
.flatten();
Ok(avg)
}
}
#[derive(Debug, Clone)]
pub struct DatabaseStats {
pub session_count: i32,
pub message_count: i32,
pub link_count: i32,
pub oldest_session: Option<DateTime<Utc>>,
pub newest_session: Option<DateTime<Utc>>,
pub sessions_by_tool: Vec<(String, i32)>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::models::{
LinkCreator, LinkType, MessageContent, MessageRole, SearchOptions,
};
use chrono::{Duration, Utc};
use tempfile::tempdir;
fn create_test_db() -> (Database, tempfile::TempDir) {
let dir = tempdir().expect("Failed to create temp directory");
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path).expect("Failed to open test database");
(db, dir)
}
fn create_test_session(
tool: &str,
working_directory: &str,
started_at: chrono::DateTime<Utc>,
source_path: Option<&str>,
) -> Session {
Session {
id: Uuid::new_v4(),
tool: tool.to_string(),
tool_version: Some("1.0.0".to_string()),
started_at,
ended_at: None,
model: Some("test-model".to_string()),
working_directory: working_directory.to_string(),
git_branch: Some("main".to_string()),
source_path: source_path.map(|s| s.to_string()),
message_count: 0,
machine_id: Some("test-machine".to_string()),
}
}
fn create_test_message(
session_id: Uuid,
index: i32,
role: MessageRole,
content: &str,
) -> Message {
Message {
id: Uuid::new_v4(),
session_id,
parent_id: None,
index,
timestamp: Utc::now(),
role,
content: MessageContent::Text(content.to_string()),
model: Some("test-model".to_string()),
git_branch: Some("main".to_string()),
cwd: Some("/test/cwd".to_string()),
}
}
fn create_test_link(
session_id: Uuid,
commit_sha: Option<&str>,
link_type: LinkType,
) -> SessionLink {
SessionLink {
id: Uuid::new_v4(),
session_id,
link_type,
commit_sha: commit_sha.map(|s| s.to_string()),
branch: Some("main".to_string()),
remote: Some("origin".to_string()),
created_at: Utc::now(),
created_by: LinkCreator::Auto,
confidence: Some(0.95),
}
}
#[test]
fn test_insert_and_get_session() {
let (db, _dir) = create_test_db();
let session = create_test_session(
"claude-code",
"/home/user/project",
Utc::now(),
Some("/path/to/source.jsonl"),
);
db.insert_session(&session)
.expect("Failed to insert session");
let retrieved = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(retrieved.id, session.id, "Session ID should match");
assert_eq!(retrieved.tool, session.tool, "Tool should match");
assert_eq!(
retrieved.tool_version, session.tool_version,
"Tool version should match"
);
assert_eq!(
retrieved.working_directory, session.working_directory,
"Working directory should match"
);
assert_eq!(
retrieved.git_branch, session.git_branch,
"Git branch should match"
);
assert_eq!(
retrieved.source_path, session.source_path,
"Source path should match"
);
}
#[test]
fn test_list_sessions() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session1 =
create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
let session2 = create_test_session("cursor", "/project2", now - Duration::hours(1), None);
let session3 = create_test_session("claude-code", "/project3", now, None);
db.insert_session(&session1)
.expect("Failed to insert session1");
db.insert_session(&session2)
.expect("Failed to insert session2");
db.insert_session(&session3)
.expect("Failed to insert session3");
let sessions = db.list_sessions(10, None).expect("Failed to list sessions");
assert_eq!(sessions.len(), 3, "Should have 3 sessions");
assert_eq!(
sessions[0].id, session3.id,
"Most recent session should be first"
);
assert_eq!(
sessions[1].id, session2.id,
"Second most recent session should be second"
);
assert_eq!(sessions[2].id, session1.id, "Oldest session should be last");
}
#[test]
fn test_list_ended_sessions() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut ended = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(60),
None,
);
ended.ended_at = Some(now - Duration::minutes(30));
let ongoing = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(10),
None,
);
db.insert_session(&ended).expect("insert ended session");
db.insert_session(&ongoing).expect("insert ongoing session");
let sessions = db
.list_ended_sessions(100, None)
.expect("Failed to list ended sessions");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, ended.id);
}
#[test]
fn test_list_sessions_with_working_dir_filter() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session1 = create_test_session(
"claude-code",
"/home/user/project-a",
now - Duration::hours(1),
None,
);
let session2 = create_test_session("claude-code", "/home/user/project-b", now, None);
let session3 = create_test_session("claude-code", "/other/path", now, None);
db.insert_session(&session1)
.expect("Failed to insert session1");
db.insert_session(&session2)
.expect("Failed to insert session2");
db.insert_session(&session3)
.expect("Failed to insert session3");
let sessions = db
.list_sessions(10, Some("/home/user"))
.expect("Failed to list sessions");
assert_eq!(
sessions.len(),
2,
"Should have 2 sessions matching /home/user prefix"
);
let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
assert!(ids.contains(&session1.id), "Should contain session1");
assert!(ids.contains(&session2.id), "Should contain session2");
assert!(!ids.contains(&session3.id), "Should not contain session3");
}
#[test]
fn test_session_exists_by_source() {
let (db, _dir) = create_test_db();
let source_path = "/path/to/session.jsonl";
let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
assert!(
!db.session_exists_by_source(source_path)
.expect("Failed to check existence"),
"Session should not exist before insert"
);
db.insert_session(&session)
.expect("Failed to insert session");
assert!(
db.session_exists_by_source(source_path)
.expect("Failed to check existence"),
"Session should exist after insert"
);
assert!(
!db.session_exists_by_source("/other/path.jsonl")
.expect("Failed to check existence"),
"Different source path should not exist"
);
}
#[test]
fn test_get_session_by_source() {
let (db, _dir) = create_test_db();
let source_path = "/path/to/session.jsonl";
let session = create_test_session("claude-code", "/project", Utc::now(), Some(source_path));
assert!(
db.get_session_by_source(source_path)
.expect("Failed to get session")
.is_none(),
"Session should not exist before insert"
);
db.insert_session(&session)
.expect("Failed to insert session");
let retrieved = db
.get_session_by_source(source_path)
.expect("Failed to get session")
.expect("Session should exist after insert");
assert_eq!(retrieved.id, session.id, "Session ID should match");
assert_eq!(
retrieved.source_path,
Some(source_path.to_string()),
"Source path should match"
);
assert!(
db.get_session_by_source("/other/path.jsonl")
.expect("Failed to get session")
.is_none(),
"Different source path should return None"
);
}
#[test]
fn test_update_session_branch() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session = create_test_session("claude-code", "/project", now, None);
session.git_branch = Some("main".to_string());
db.insert_session(&session)
.expect("Failed to insert session");
let fetched = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(fetched.git_branch, Some("main".to_string()));
let rows = db
.update_session_branch(session.id, "feature-branch")
.expect("Failed to update branch");
assert_eq!(rows, 1, "Should update exactly one row");
let fetched = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(fetched.git_branch, Some("feature-branch".to_string()));
}
#[test]
fn test_update_session_branch_nonexistent() {
let (db, _dir) = create_test_db();
let nonexistent_id = Uuid::new_v4();
let rows = db
.update_session_branch(nonexistent_id, "some-branch")
.expect("Failed to update branch");
assert_eq!(
rows, 0,
"Should not update any rows for nonexistent session"
);
}
#[test]
fn test_update_session_branch_from_none() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session = create_test_session("claude-code", "/project", now, None);
session.git_branch = None;
db.insert_session(&session)
.expect("Failed to insert session");
let fetched = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(fetched.git_branch, None);
let rows = db
.update_session_branch(session.id, "new-branch")
.expect("Failed to update branch");
assert_eq!(rows, 1, "Should update exactly one row");
let fetched = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(fetched.git_branch, Some("new-branch".to_string()));
}
#[test]
fn test_get_nonexistent_session() {
let (db, _dir) = create_test_db();
let nonexistent_id = Uuid::new_v4();
let result = db
.get_session(&nonexistent_id)
.expect("Failed to query for nonexistent session");
assert!(
result.is_none(),
"Should return None for nonexistent session"
);
}
#[test]
fn test_insert_and_get_messages() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!");
db.insert_message(&msg1)
.expect("Failed to insert message 1");
db.insert_message(&msg2)
.expect("Failed to insert message 2");
let messages = db
.get_messages(&session.id)
.expect("Failed to get messages");
assert_eq!(messages.len(), 2, "Should have 2 messages");
assert_eq!(messages[0].id, msg1.id, "First message ID should match");
assert_eq!(messages[1].id, msg2.id, "Second message ID should match");
assert_eq!(
messages[0].role,
MessageRole::User,
"First message role should be User"
);
assert_eq!(
messages[1].role,
MessageRole::Assistant,
"Second message role should be Assistant"
);
}
#[test]
fn test_messages_ordered_by_index() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let msg3 = create_test_message(session.id, 2, MessageRole::Assistant, "Third");
let msg1 = create_test_message(session.id, 0, MessageRole::User, "First");
let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Second");
db.insert_message(&msg3)
.expect("Failed to insert message 3");
db.insert_message(&msg1)
.expect("Failed to insert message 1");
db.insert_message(&msg2)
.expect("Failed to insert message 2");
let messages = db
.get_messages(&session.id)
.expect("Failed to get messages");
assert_eq!(messages.len(), 3, "Should have 3 messages");
assert_eq!(messages[0].index, 0, "First message should have index 0");
assert_eq!(messages[1].index, 1, "Second message should have index 1");
assert_eq!(messages[2].index, 2, "Third message should have index 2");
assert_eq!(
messages[0].content.text(),
"First",
"First message content should be 'First'"
);
assert_eq!(
messages[1].content.text(),
"Second",
"Second message content should be 'Second'"
);
assert_eq!(
messages[2].content.text(),
"Third",
"Third message content should be 'Third'"
);
}
#[test]
fn test_insert_and_get_links_by_session() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
db.insert_link(&link1).expect("Failed to insert link 1");
db.insert_link(&link2).expect("Failed to insert link 2");
let links = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links.len(), 2, "Should have 2 links");
let link_ids: Vec<Uuid> = links.iter().map(|l| l.id).collect();
assert!(link_ids.contains(&link1.id), "Should contain link1");
assert!(link_ids.contains(&link2.id), "Should contain link2");
let retrieved_link = links.iter().find(|l| l.id == link1.id).unwrap();
assert_eq!(
retrieved_link.commit_sha,
Some("abc123def456".to_string()),
"Commit SHA should match"
);
assert_eq!(
retrieved_link.link_type,
LinkType::Commit,
"Link type should be Commit"
);
assert_eq!(
retrieved_link.created_by,
LinkCreator::Auto,
"Created by should be Auto"
);
}
#[test]
fn test_get_links_by_commit() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let full_sha = "abc123def456789012345678901234567890abcd";
let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
db.insert_link(&link).expect("Failed to insert link");
let links_full = db
.get_links_by_commit(full_sha)
.expect("Failed to get links by full SHA");
assert_eq!(links_full.len(), 1, "Should find link by full SHA");
assert_eq!(links_full[0].id, link.id, "Link ID should match");
let links_partial = db
.get_links_by_commit("abc123")
.expect("Failed to get links by partial SHA");
assert_eq!(
links_partial.len(),
1,
"Should find link by partial SHA prefix"
);
assert_eq!(links_partial[0].id, link.id, "Link ID should match");
let links_none = db
.get_links_by_commit("zzz999")
.expect("Failed to get links by non-matching SHA");
assert_eq!(
links_none.len(),
0,
"Should not find link with non-matching SHA"
);
}
#[test]
fn test_database_creation() {
let dir = tempdir().expect("Failed to create temp directory");
let db_path = dir.path().join("new_test.db");
assert!(
!db_path.exists(),
"Database file should not exist before creation"
);
let db = Database::open(&db_path).expect("Failed to create database");
assert!(
db_path.exists(),
"Database file should exist after creation"
);
let session_count = db.session_count().expect("Failed to get session count");
assert_eq!(session_count, 0, "New database should have 0 sessions");
let message_count = db.message_count().expect("Failed to get message count");
assert_eq!(message_count, 0, "New database should have 0 messages");
}
#[test]
fn test_session_count() {
let (db, _dir) = create_test_db();
assert_eq!(
db.session_count().expect("Failed to get count"),
0,
"Initial session count should be 0"
);
let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
db.insert_session(&session1)
.expect("Failed to insert session1");
assert_eq!(
db.session_count().expect("Failed to get count"),
1,
"Session count should be 1 after first insert"
);
let session2 = create_test_session("cursor", "/project2", Utc::now(), None);
db.insert_session(&session2)
.expect("Failed to insert session2");
assert_eq!(
db.session_count().expect("Failed to get count"),
2,
"Session count should be 2 after second insert"
);
}
#[test]
fn test_message_count() {
let (db, _dir) = create_test_db();
assert_eq!(
db.message_count().expect("Failed to get count"),
0,
"Initial message count should be 0"
);
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
db.insert_message(&msg1).expect("Failed to insert message1");
assert_eq!(
db.message_count().expect("Failed to get count"),
1,
"Message count should be 1 after first insert"
);
let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi");
let msg3 = create_test_message(session.id, 2, MessageRole::User, "How are you?");
db.insert_message(&msg2).expect("Failed to insert message2");
db.insert_message(&msg3).expect("Failed to insert message3");
assert_eq!(
db.message_count().expect("Failed to get count"),
3,
"Message count should be 3 after all inserts"
);
}
#[test]
fn test_link_count() {
let (db, _dir) = create_test_db();
assert_eq!(
db.link_count().expect("Failed to get count"),
0,
"Initial link count should be 0"
);
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
db.insert_link(&link1).expect("Failed to insert link1");
assert_eq!(
db.link_count().expect("Failed to get count"),
1,
"Link count should be 1 after first insert"
);
let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
db.insert_link(&link2).expect("Failed to insert link2");
assert_eq!(
db.link_count().expect("Failed to get count"),
2,
"Link count should be 2 after second insert"
);
}
#[test]
fn test_db_path() {
let dir = tempdir().expect("Failed to create temp directory");
let db_path = dir.path().join("test.db");
let db = Database::open(&db_path).expect("Failed to open test database");
let retrieved_path = db.db_path();
assert!(
retrieved_path.is_some(),
"Database path should be available"
);
let expected = db_path.canonicalize().unwrap_or(db_path);
let actual = retrieved_path.unwrap();
let actual_canonical = actual.canonicalize().unwrap_or(actual.clone());
assert_eq!(
actual_canonical, expected,
"Database path should match (after canonicalization)"
);
}
#[test]
fn test_search_messages_basic() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let msg1 = create_test_message(
session.id,
0,
MessageRole::User,
"How do I implement error handling in Rust?",
);
let msg2 = create_test_message(
session.id,
1,
MessageRole::Assistant,
"You can use Result types for error handling. The anyhow crate is also helpful.",
);
db.insert_message(&msg1)
.expect("Failed to insert message 1");
db.insert_message(&msg2)
.expect("Failed to insert message 2");
let results = db
.search_messages("error", 10, None, None, None)
.expect("Failed to search");
assert_eq!(
results.len(),
2,
"Should find 2 messages containing 'error'"
);
}
#[test]
fn test_search_messages_no_results() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let msg = create_test_message(session.id, 0, MessageRole::User, "Hello world");
db.insert_message(&msg).expect("Failed to insert message");
let results = db
.search_messages("nonexistent_term_xyz", 10, None, None, None)
.expect("Failed to search");
assert!(results.is_empty(), "Should find no results");
}
#[test]
fn test_search_messages_with_role_filter() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let msg1 = create_test_message(
session.id,
0,
MessageRole::User,
"Tell me about Rust programming",
);
let msg2 = create_test_message(
session.id,
1,
MessageRole::Assistant,
"Rust is a systems programming language",
);
db.insert_message(&msg1)
.expect("Failed to insert message 1");
db.insert_message(&msg2)
.expect("Failed to insert message 2");
let user_results = db
.search_messages("programming", 10, None, None, Some("user"))
.expect("Failed to search");
assert_eq!(user_results.len(), 1, "Should find 1 user message");
assert_eq!(
user_results[0].role,
MessageRole::User,
"Result should be from user"
);
let assistant_results = db
.search_messages("programming", 10, None, None, Some("assistant"))
.expect("Failed to search");
assert_eq!(
assistant_results.len(),
1,
"Should find 1 assistant message"
);
assert_eq!(
assistant_results[0].role,
MessageRole::Assistant,
"Result should be from assistant"
);
}
#[test]
fn test_search_messages_with_repo_filter() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/home/user/project-a", Utc::now(), None);
let session2 = create_test_session("claude-code", "/home/user/project-b", Utc::now(), None);
db.insert_session(&session1).expect("insert 1");
db.insert_session(&session2).expect("insert 2");
let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from project-a");
let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from project-b");
db.insert_message(&msg1).expect("insert msg 1");
db.insert_message(&msg2).expect("insert msg 2");
let results = db
.search_messages("Hello", 10, Some("/home/user/project-a"), None, None)
.expect("Failed to search");
assert_eq!(results.len(), 1, "Should find 1 message in project-a");
assert!(
results[0].working_directory.contains("project-a"),
"Should be from project-a"
);
}
#[test]
fn test_search_messages_limit() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
for i in 0..5 {
let msg = create_test_message(
session.id,
i,
MessageRole::User,
&format!("This is test message number {i}"),
);
db.insert_message(&msg).expect("insert message");
}
let results = db
.search_messages("test", 3, None, None, None)
.expect("Failed to search");
assert_eq!(results.len(), 3, "Should respect limit of 3");
}
#[test]
fn test_search_index_needs_rebuild_empty_db() {
let (db, _dir) = create_test_db();
let needs_rebuild = db
.search_index_needs_rebuild()
.expect("Failed to check rebuild status");
assert!(!needs_rebuild, "Empty database should not need rebuild");
}
#[test]
fn test_rebuild_search_index() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let msg1 = create_test_message(session.id, 0, MessageRole::User, "First test message");
let msg2 = create_test_message(
session.id,
1,
MessageRole::Assistant,
"Second test response",
);
db.insert_message(&msg1).expect("insert msg 1");
db.insert_message(&msg2).expect("insert msg 2");
db.conn
.execute("DELETE FROM messages_fts", [])
.expect("clear fts");
assert!(
db.search_index_needs_rebuild().expect("check rebuild"),
"Should need rebuild after clearing FTS"
);
let count = db.rebuild_search_index().expect("rebuild");
assert_eq!(count, 2, "Should have indexed 2 messages");
assert!(
!db.search_index_needs_rebuild().expect("check rebuild"),
"Should not need rebuild after rebuilding"
);
let results = db
.search_messages("test", 10, None, None, None)
.expect("search");
assert_eq!(results.len(), 2, "Should find 2 results after rebuild");
}
#[test]
fn test_search_with_block_content() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let block_content = MessageContent::Blocks(vec![
crate::storage::models::ContentBlock::Text {
text: "Let me help with your database query.".to_string(),
},
crate::storage::models::ContentBlock::ToolUse {
id: "tool_123".to_string(),
name: "Bash".to_string(),
input: serde_json::json!({"command": "ls -la"}),
},
]);
let msg = Message {
id: Uuid::new_v4(),
session_id: session.id,
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::Assistant,
content: block_content,
model: Some("claude-opus-4".to_string()),
git_branch: Some("main".to_string()),
cwd: Some("/project".to_string()),
};
db.insert_message(&msg).expect("insert message");
let results = db
.search_messages("database", 10, None, None, None)
.expect("search");
assert_eq!(results.len(), 1, "Should find message with block content");
}
#[test]
fn test_search_result_contains_session_info() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/home/user/my-project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let msg = create_test_message(session.id, 0, MessageRole::User, "Search test message");
db.insert_message(&msg).expect("insert message");
let results = db
.search_messages("Search", 10, None, None, None)
.expect("search");
assert_eq!(results.len(), 1, "Should find 1 result");
assert_eq!(results[0].session_id, session.id, "Session ID should match");
assert_eq!(results[0].message_id, msg.id, "Message ID should match");
assert_eq!(
results[0].working_directory, "/home/user/my-project",
"Working directory should match"
);
assert_eq!(results[0].role, MessageRole::User, "Role should match");
}
#[test]
fn test_delete_link_by_id() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
db.insert_link(&link).expect("Failed to insert link");
let links_before = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links_before.len(), 1, "Should have 1 link before delete");
let deleted = db.delete_link(&link.id).expect("Failed to delete link");
assert!(deleted, "Should return true when link is deleted");
let links_after = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
}
#[test]
fn test_delete_link_nonexistent() {
let (db, _dir) = create_test_db();
let nonexistent_id = Uuid::new_v4();
let deleted = db
.delete_link(&nonexistent_id)
.expect("Failed to call delete_link");
assert!(!deleted, "Should return false for nonexistent link");
}
#[test]
fn test_delete_links_by_session() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let link1 = create_test_link(session.id, Some("abc123"), LinkType::Commit);
let link2 = create_test_link(session.id, Some("def456"), LinkType::Commit);
let link3 = create_test_link(session.id, Some("ghi789"), LinkType::Commit);
db.insert_link(&link1).expect("Failed to insert link1");
db.insert_link(&link2).expect("Failed to insert link2");
db.insert_link(&link3).expect("Failed to insert link3");
let links_before = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links_before.len(), 3, "Should have 3 links before delete");
let count = db
.delete_links_by_session(&session.id)
.expect("Failed to delete links");
assert_eq!(count, 3, "Should have deleted 3 links");
let links_after = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links_after.len(), 0, "Should have 0 links after delete");
}
#[test]
fn test_delete_links_by_session_no_links() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let count = db
.delete_links_by_session(&session.id)
.expect("Failed to call delete_links_by_session");
assert_eq!(count, 0, "Should return 0 when no links exist");
}
#[test]
fn test_delete_links_by_session_preserves_other_sessions() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
db.insert_session(&session1)
.expect("Failed to insert session1");
db.insert_session(&session2)
.expect("Failed to insert session2");
let link1 = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
let link2 = create_test_link(session2.id, Some("def456"), LinkType::Commit);
db.insert_link(&link1).expect("Failed to insert link1");
db.insert_link(&link2).expect("Failed to insert link2");
let count = db
.delete_links_by_session(&session1.id)
.expect("Failed to delete links");
assert_eq!(count, 1, "Should have deleted 1 link");
let session2_links = db
.get_links_by_session(&session2.id)
.expect("Failed to get links");
assert_eq!(
session2_links.len(),
1,
"Session2's link should be preserved"
);
assert_eq!(session2_links[0].id, link2.id, "Link ID should match");
}
#[test]
fn test_delete_link_by_session_and_commit() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let link1 = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
let link2 = create_test_link(session.id, Some("def456abc789"), LinkType::Commit);
db.insert_link(&link1).expect("Failed to insert link1");
db.insert_link(&link2).expect("Failed to insert link2");
let deleted = db
.delete_link_by_session_and_commit(&session.id, "abc123")
.expect("Failed to delete link");
assert!(deleted, "Should return true when link is deleted");
let links = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links.len(), 1, "Should have 1 link remaining");
assert_eq!(links[0].id, link2.id, "Remaining link should be link2");
}
#[test]
fn test_delete_link_by_session_and_commit_full_sha() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let full_sha = "abc123def456789012345678901234567890abcd";
let link = create_test_link(session.id, Some(full_sha), LinkType::Commit);
db.insert_link(&link).expect("Failed to insert link");
let deleted = db
.delete_link_by_session_and_commit(&session.id, full_sha)
.expect("Failed to delete link");
assert!(deleted, "Should delete with full SHA");
let links = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links.len(), 0, "Should have 0 links after delete");
}
#[test]
fn test_delete_link_by_session_and_commit_no_match() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
db.insert_link(&link).expect("Failed to insert link");
let deleted = db
.delete_link_by_session_and_commit(&session.id, "xyz999")
.expect("Failed to call delete");
assert!(!deleted, "Should return false when no match");
let links = db
.get_links_by_session(&session.id)
.expect("Failed to get links");
assert_eq!(links.len(), 1, "Link should be preserved");
}
#[test]
fn test_delete_link_by_session_and_commit_wrong_session() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
db.insert_session(&session1)
.expect("Failed to insert session1");
db.insert_session(&session2)
.expect("Failed to insert session2");
let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
db.insert_link(&link).expect("Failed to insert link");
let deleted = db
.delete_link_by_session_and_commit(&session2.id, "abc123")
.expect("Failed to call delete");
assert!(!deleted, "Should not delete link from different session");
let links = db
.get_links_by_session(&session1.id)
.expect("Failed to get links");
assert_eq!(links.len(), 1, "Link should be preserved");
}
#[test]
fn test_find_sessions_near_commit_time_basic() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::hours(1),
None,
);
session.ended_at = Some(now - Duration::minutes(10));
db.insert_session(&session).expect("insert session");
let found = db
.find_sessions_near_commit_time(now, 30, None)
.expect("find sessions");
assert_eq!(found.len(), 1, "Should find session within window");
assert_eq!(found[0].id, session.id);
}
#[test]
fn test_find_sessions_near_commit_time_outside_window() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session =
create_test_session("claude-code", "/project", now - Duration::hours(3), None);
session.ended_at = Some(now - Duration::hours(2));
db.insert_session(&session).expect("insert session");
let found = db
.find_sessions_near_commit_time(now, 30, None)
.expect("find sessions");
assert!(found.is_empty(), "Should not find session outside window");
}
#[test]
fn test_find_sessions_near_commit_time_with_working_dir() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session1 = create_test_session(
"claude-code",
"/home/user/project-a",
now - Duration::minutes(30),
None,
);
session1.ended_at = Some(now - Duration::minutes(5));
let mut session2 = create_test_session(
"claude-code",
"/home/user/project-b",
now - Duration::minutes(30),
None,
);
session2.ended_at = Some(now - Duration::minutes(5));
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let found = db
.find_sessions_near_commit_time(now, 30, Some("/home/user/project-a"))
.expect("find sessions");
assert_eq!(found.len(), 1, "Should find only session in project-a");
assert_eq!(found[0].id, session1.id);
}
#[test]
fn test_find_sessions_near_commit_time_ongoing_session() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session =
create_test_session("claude-code", "/project", now - Duration::minutes(20), None);
db.insert_session(&session).expect("insert session");
let found = db
.find_sessions_near_commit_time(now, 30, None)
.expect("find sessions");
assert_eq!(found.len(), 1, "Should find ongoing session");
assert_eq!(found[0].id, session.id);
}
#[test]
fn test_link_exists_true() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let link = create_test_link(session.id, Some("abc123def456"), LinkType::Commit);
db.insert_link(&link).expect("insert link");
assert!(
db.link_exists(&session.id, "abc123def456")
.expect("check exists"),
"Should find link with full SHA"
);
assert!(
db.link_exists(&session.id, "abc123").expect("check exists"),
"Should find link with partial SHA"
);
}
#[test]
fn test_link_exists_false() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
assert!(
!db.link_exists(&session.id, "abc123").expect("check exists"),
"Should not find non-existent link"
);
}
#[test]
fn test_link_exists_different_session() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
let session2 = create_test_session("claude-code", "/project2", Utc::now(), None);
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
db.insert_link(&link).expect("insert link");
assert!(
db.link_exists(&session1.id, "abc123").expect("check"),
"Should find link for session1"
);
assert!(
!db.link_exists(&session2.id, "abc123").expect("check"),
"Should not find link for session2"
);
}
#[test]
fn test_find_active_sessions_for_directory_ongoing() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(30),
None,
);
db.insert_session(&session).expect("insert session");
let found = db
.find_active_sessions_for_directory("/home/user/project", None)
.expect("find active sessions");
assert_eq!(found.len(), 1, "Should find ongoing session");
assert_eq!(found[0].id, session.id);
}
#[test]
fn test_find_active_sessions_for_directory_recently_ended() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(30),
None,
);
session.ended_at = Some(now - Duration::minutes(2));
db.insert_session(&session).expect("insert session");
let found = db
.find_active_sessions_for_directory("/home/user/project", None)
.expect("find active sessions");
assert_eq!(found.len(), 1, "Should find recently ended session");
assert_eq!(found[0].id, session.id);
}
#[test]
fn test_find_active_sessions_for_directory_old_session() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(60),
None,
);
session.ended_at = Some(now - Duration::minutes(10));
db.insert_session(&session).expect("insert session");
let found = db
.find_active_sessions_for_directory("/home/user/project", None)
.expect("find active sessions");
assert!(found.is_empty(), "Should not find old session");
}
#[test]
fn test_find_active_sessions_for_directory_filters_by_path() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session1 = create_test_session(
"claude-code",
"/home/user/project-a",
now - Duration::minutes(10),
None,
);
let session2 = create_test_session(
"claude-code",
"/home/user/project-b",
now - Duration::minutes(10),
None,
);
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let found = db
.find_active_sessions_for_directory("/home/user/project-a", None)
.expect("find active sessions");
assert_eq!(found.len(), 1, "Should find only session in project-a");
assert_eq!(found[0].id, session1.id);
}
#[test]
fn test_find_active_sessions_for_directory_trailing_slash_matches() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(10),
None,
);
db.insert_session(&session).expect("insert session");
let found = db
.find_active_sessions_for_directory("/home/user/project/", None)
.expect("find active sessions");
assert_eq!(found.len(), 1, "Should match even with trailing slash");
assert_eq!(found[0].id, session.id);
}
#[test]
fn test_find_active_sessions_for_directory_does_not_match_prefix_siblings() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session_root = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(10),
None,
);
let session_subdir = create_test_session(
"claude-code",
"/home/user/project/src",
now - Duration::minutes(10),
None,
);
let session_sibling = create_test_session(
"claude-code",
"/home/user/project-old",
now - Duration::minutes(10),
None,
);
db.insert_session(&session_root)
.expect("insert session_root");
db.insert_session(&session_subdir)
.expect("insert session_subdir");
db.insert_session(&session_sibling)
.expect("insert session_sibling");
let found = db
.find_active_sessions_for_directory("/home/user/project", None)
.expect("find active sessions");
let found_ids: std::collections::HashSet<Uuid> =
found.iter().map(|session| session.id).collect();
assert!(found_ids.contains(&session_root.id));
assert!(found_ids.contains(&session_subdir.id));
assert!(!found_ids.contains(&session_sibling.id));
}
#[test]
fn test_find_active_sessions_for_directory_custom_window() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::minutes(30),
None,
);
session.ended_at = Some(now - Duration::minutes(8));
db.insert_session(&session).expect("insert session");
let found = db
.find_active_sessions_for_directory("/home/user/project", None)
.expect("find with default window");
assert!(found.is_empty(), "Should not find with 5 minute window");
let found = db
.find_active_sessions_for_directory("/home/user/project", Some(10))
.expect("find with 10 minute window");
assert_eq!(found.len(), 1, "Should find with 10 minute window");
}
#[test]
fn test_search_with_tool_filter() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
let session2 = create_test_session("aider", "/project2", Utc::now(), None);
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello from Claude");
let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello from Aider");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let options = SearchOptions {
query: "Hello".to_string(),
limit: 10,
tool: Some("claude-code".to_string()),
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert_eq!(results.len(), 1, "Should find 1 result with tool filter");
assert_eq!(results[0].tool, "claude-code", "Should be from claude-code");
}
#[test]
fn test_search_with_date_range() {
let (db, _dir) = create_test_db();
let old_time = Utc::now() - chrono::Duration::days(30);
let new_time = Utc::now() - chrono::Duration::days(1);
let session1 = create_test_session("claude-code", "/project1", old_time, None);
let session2 = create_test_session("claude-code", "/project2", new_time, None);
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Old session message");
let msg2 = create_test_message(session2.id, 0, MessageRole::User, "New session message");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let since = Utc::now() - chrono::Duration::days(7);
let options = SearchOptions {
query: "session".to_string(),
limit: 10,
since: Some(since),
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert_eq!(results.len(), 1, "Should find 1 result within date range");
assert!(
results[0].working_directory.contains("project2"),
"Should be from newer project"
);
}
#[test]
fn test_search_with_project_filter() {
let (db, _dir) = create_test_db();
let session1 =
create_test_session("claude-code", "/home/user/frontend-app", Utc::now(), None);
let session2 =
create_test_session("claude-code", "/home/user/backend-api", Utc::now(), None);
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Testing frontend");
let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Testing backend");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let options = SearchOptions {
query: "Testing".to_string(),
limit: 10,
project: Some("frontend".to_string()),
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert_eq!(results.len(), 1, "Should find 1 result with project filter");
assert!(
results[0].working_directory.contains("frontend"),
"Should be from frontend project"
);
}
#[test]
fn test_search_with_branch_filter() {
let (db, _dir) = create_test_db();
let session1 = Session {
id: Uuid::new_v4(),
tool: "claude-code".to_string(),
tool_version: None,
started_at: Utc::now(),
ended_at: None,
model: None,
working_directory: "/project".to_string(),
git_branch: Some("feat/auth".to_string()),
source_path: None,
message_count: 0,
machine_id: None,
};
let session2 = Session {
id: Uuid::new_v4(),
tool: "claude-code".to_string(),
tool_version: None,
started_at: Utc::now(),
ended_at: None,
model: None,
working_directory: "/project".to_string(),
git_branch: Some("main".to_string()),
source_path: None,
message_count: 0,
machine_id: None,
};
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Auth feature work");
let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Main branch work");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let options = SearchOptions {
query: "work".to_string(),
limit: 10,
branch: Some("auth".to_string()),
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert_eq!(results.len(), 1, "Should find 1 result with branch filter");
assert_eq!(
results[0].git_branch.as_deref(),
Some("feat/auth"),
"Should be from feat/auth branch"
);
}
#[test]
fn test_search_metadata_matches_project() {
let (db, _dir) = create_test_db();
let session =
create_test_session("claude-code", "/home/user/redactyl-app", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let msg = create_test_message(session.id, 0, MessageRole::User, "Working on the project");
db.insert_message(&msg).expect("insert msg");
let options = SearchOptions {
query: "redactyl".to_string(),
limit: 10,
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert_eq!(
results.len(),
1,
"Should find session via metadata match on project name"
);
}
#[test]
fn test_search_returns_extended_session_info() {
let (db, _dir) = create_test_db();
let started_at = Utc::now();
let session = Session {
id: Uuid::new_v4(),
tool: "claude-code".to_string(),
tool_version: Some("1.0.0".to_string()),
started_at,
ended_at: None,
model: None,
working_directory: "/home/user/myapp".to_string(),
git_branch: Some("develop".to_string()),
source_path: None,
message_count: 5,
machine_id: None,
};
db.insert_session(&session).expect("insert session");
let msg = create_test_message(session.id, 0, MessageRole::User, "Test message for search");
db.insert_message(&msg).expect("insert msg");
let options = SearchOptions {
query: "Test".to_string(),
limit: 10,
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert_eq!(results.len(), 1, "Should find 1 result");
let result = &results[0];
assert_eq!(result.tool, "claude-code", "Tool should be populated");
assert_eq!(
result.git_branch.as_deref(),
Some("develop"),
"Branch should be populated"
);
assert!(
result.session_message_count > 0,
"Message count should be populated"
);
assert!(
result.session_started_at.is_some(),
"Session start time should be populated"
);
}
#[test]
fn test_get_context_messages() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
for i in 0..5 {
let role = if i % 2 == 0 {
MessageRole::User
} else {
MessageRole::Assistant
};
let msg = create_test_message(session.id, i, role, &format!("Message number {i}"));
db.insert_message(&msg).expect("insert message");
}
let (before, after) = db
.get_context_messages(&session.id, 2, 1)
.expect("get context");
assert_eq!(before.len(), 1, "Should have 1 message before");
assert_eq!(after.len(), 1, "Should have 1 message after");
assert_eq!(before[0].index, 1, "Before message should be index 1");
assert_eq!(after[0].index, 3, "After message should be index 3");
}
#[test]
fn test_get_context_messages_at_start() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
for i in 0..3 {
let msg =
create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
db.insert_message(&msg).expect("insert message");
}
let (before, after) = db
.get_context_messages(&session.id, 0, 2)
.expect("get context");
assert!(
before.is_empty(),
"Should have no messages before first message"
);
assert_eq!(after.len(), 2, "Should have 2 messages after");
}
#[test]
fn test_get_context_messages_at_end() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
for i in 0..3 {
let msg =
create_test_message(session.id, i, MessageRole::User, &format!("Message {i}"));
db.insert_message(&msg).expect("insert message");
}
let (before, after) = db
.get_context_messages(&session.id, 2, 2)
.expect("get context");
assert_eq!(before.len(), 2, "Should have 2 messages before");
assert!(
after.is_empty(),
"Should have no messages after last message"
);
}
#[test]
fn test_search_combined_filters() {
let (db, _dir) = create_test_db();
let session1 = Session {
id: Uuid::new_v4(),
tool: "claude-code".to_string(),
tool_version: None,
started_at: Utc::now(),
ended_at: None,
model: None,
working_directory: "/home/user/myapp".to_string(),
git_branch: Some("feat/api".to_string()),
source_path: None,
message_count: 1,
machine_id: None,
};
let session2 = Session {
id: Uuid::new_v4(),
tool: "aider".to_string(),
tool_version: None,
started_at: Utc::now(),
ended_at: None,
model: None,
working_directory: "/home/user/myapp".to_string(),
git_branch: Some("feat/api".to_string()),
source_path: None,
message_count: 1,
machine_id: None,
};
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let msg1 =
create_test_message(session1.id, 0, MessageRole::User, "API implementation work");
let msg2 =
create_test_message(session2.id, 0, MessageRole::User, "API implementation work");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let options = SearchOptions {
query: "API".to_string(),
limit: 10,
tool: Some("claude-code".to_string()),
branch: Some("api".to_string()),
project: Some("myapp".to_string()),
..Default::default()
};
let results = db.search_with_options(&options).expect("search");
assert!(
!results.is_empty(),
"Should find at least 1 result matching all filters"
);
for result in &results {
assert_eq!(
result.tool, "claude-code",
"All results should be from claude-code"
);
}
}
#[test]
fn test_delete_session_removes_all_data() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let msg1 = create_test_message(session.id, 0, MessageRole::User, "Hello");
let msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "Hi there");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let link = create_test_link(session.id, Some("abc123"), LinkType::Commit);
db.insert_link(&link).expect("insert link");
assert_eq!(db.session_count().expect("count"), 1);
assert_eq!(db.message_count().expect("count"), 2);
assert_eq!(db.link_count().expect("count"), 1);
let (msgs_deleted, links_deleted) = db.delete_session(&session.id).expect("delete");
assert_eq!(msgs_deleted, 2, "Should delete 2 messages");
assert_eq!(links_deleted, 1, "Should delete 1 link");
assert_eq!(db.session_count().expect("count"), 0);
assert_eq!(db.message_count().expect("count"), 0);
assert_eq!(db.link_count().expect("count"), 0);
assert!(db.get_session(&session.id).expect("get").is_none());
}
#[test]
fn test_delete_session_preserves_other_sessions() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/project1", Utc::now(), None);
let session2 = create_test_session("aider", "/project2", Utc::now(), None);
db.insert_session(&session1).expect("insert session1");
db.insert_session(&session2).expect("insert session2");
let msg1 = create_test_message(session1.id, 0, MessageRole::User, "Hello 1");
let msg2 = create_test_message(session2.id, 0, MessageRole::User, "Hello 2");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
db.delete_session(&session1.id).expect("delete");
assert_eq!(db.session_count().expect("count"), 1);
assert_eq!(db.message_count().expect("count"), 1);
assert!(db.get_session(&session2.id).expect("get").is_some());
}
#[test]
fn test_file_size() {
let (db, _dir) = create_test_db();
let size = db.file_size().expect("get size");
assert!(size.is_some(), "Should have file size for file-based db");
assert!(size.unwrap() > 0, "Database file should have size > 0");
}
#[test]
fn test_vacuum() {
let (db, _dir) = create_test_db();
db.vacuum().expect("vacuum should succeed");
}
#[test]
fn test_count_sessions_older_than() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let old_session =
create_test_session("claude-code", "/project1", now - Duration::days(100), None);
let recent_session =
create_test_session("claude-code", "/project2", now - Duration::days(10), None);
db.insert_session(&old_session).expect("insert old");
db.insert_session(&recent_session).expect("insert recent");
let cutoff = now - Duration::days(30);
let count = db.count_sessions_older_than(cutoff).expect("count");
assert_eq!(count, 1, "Should find 1 session older than 30 days");
let old_cutoff = now - Duration::days(200);
let old_count = db.count_sessions_older_than(old_cutoff).expect("count");
assert_eq!(old_count, 0, "Should find 0 sessions older than 200 days");
}
#[test]
fn test_delete_sessions_older_than() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let old_session =
create_test_session("claude-code", "/project1", now - Duration::days(100), None);
let recent_session =
create_test_session("claude-code", "/project2", now - Duration::days(10), None);
db.insert_session(&old_session).expect("insert old");
db.insert_session(&recent_session).expect("insert recent");
let msg1 = create_test_message(old_session.id, 0, MessageRole::User, "Old message");
let msg2 = create_test_message(recent_session.id, 0, MessageRole::User, "Recent message");
db.insert_message(&msg1).expect("insert msg1");
db.insert_message(&msg2).expect("insert msg2");
let cutoff = now - Duration::days(30);
let deleted = db.delete_sessions_older_than(cutoff).expect("delete");
assert_eq!(deleted, 1, "Should delete 1 session");
assert_eq!(db.session_count().expect("count"), 1);
assert!(db.get_session(&recent_session.id).expect("get").is_some());
assert!(db.get_session(&old_session.id).expect("get").is_none());
assert_eq!(db.message_count().expect("count"), 1);
}
#[test]
fn test_get_sessions_older_than() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let old_session = create_test_session(
"claude-code",
"/project/old",
now - Duration::days(100),
None,
);
let medium_session =
create_test_session("aider", "/project/medium", now - Duration::days(50), None);
let recent_session =
create_test_session("gemini", "/project/recent", now - Duration::days(10), None);
db.insert_session(&old_session).expect("insert old");
db.insert_session(&medium_session).expect("insert medium");
db.insert_session(&recent_session).expect("insert recent");
let cutoff = now - Duration::days(30);
let sessions = db.get_sessions_older_than(cutoff).expect("get sessions");
assert_eq!(
sessions.len(),
2,
"Should find 2 sessions older than 30 days"
);
assert_eq!(sessions[0].id, old_session.id);
assert_eq!(sessions[1].id, medium_session.id);
assert_eq!(sessions[0].tool, "claude-code");
assert_eq!(sessions[0].working_directory, "/project/old");
assert_eq!(sessions[1].tool, "aider");
assert_eq!(sessions[1].working_directory, "/project/medium");
let old_cutoff = now - Duration::days(200);
let old_sessions = db
.get_sessions_older_than(old_cutoff)
.expect("get old sessions");
assert_eq!(
old_sessions.len(),
0,
"Should find 0 sessions older than 200 days"
);
}
#[test]
fn test_stats() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let empty_stats = db.stats().expect("stats");
assert_eq!(empty_stats.session_count, 0);
assert_eq!(empty_stats.message_count, 0);
assert_eq!(empty_stats.link_count, 0);
assert!(empty_stats.oldest_session.is_none());
assert!(empty_stats.newest_session.is_none());
assert!(empty_stats.sessions_by_tool.is_empty());
let session1 =
create_test_session("claude-code", "/project1", now - Duration::hours(2), None);
let session2 = create_test_session("aider", "/project2", now - Duration::hours(1), None);
let session3 = create_test_session("claude-code", "/project3", now, None);
db.insert_session(&session1).expect("insert 1");
db.insert_session(&session2).expect("insert 2");
db.insert_session(&session3).expect("insert 3");
let msg = create_test_message(session1.id, 0, MessageRole::User, "Hello");
db.insert_message(&msg).expect("insert msg");
let link = create_test_link(session1.id, Some("abc123"), LinkType::Commit);
db.insert_link(&link).expect("insert link");
let stats = db.stats().expect("stats");
assert_eq!(stats.session_count, 3);
assert_eq!(stats.message_count, 1);
assert_eq!(stats.link_count, 1);
assert!(stats.oldest_session.is_some());
assert!(stats.newest_session.is_some());
assert_eq!(stats.sessions_by_tool.len(), 2);
assert_eq!(stats.sessions_by_tool[0].0, "claude-code");
assert_eq!(stats.sessions_by_tool[0].1, 2);
assert_eq!(stats.sessions_by_tool[1].0, "aider");
assert_eq!(stats.sessions_by_tool[1].1, 1);
}
#[test]
fn test_get_session_branch_history_no_messages() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let branches = db
.get_session_branch_history(session.id)
.expect("Failed to get branch history");
assert!(branches.is_empty(), "Empty session should have no branches");
}
#[test]
fn test_get_session_branch_history_single_branch() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
for i in 0..3 {
let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
msg.git_branch = Some("main".to_string());
db.insert_message(&msg).expect("Failed to insert message");
}
let branches = db
.get_session_branch_history(session.id)
.expect("Failed to get branch history");
assert_eq!(branches, vec!["main"], "Should have single branch");
}
#[test]
fn test_get_session_branch_history_multiple_branches() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let branch_sequence = ["main", "main", "feat/auth", "feat/auth", "main"];
for (i, branch) in branch_sequence.iter().enumerate() {
let mut msg = create_test_message(session.id, i as i32, MessageRole::User, "test");
msg.git_branch = Some(branch.to_string());
db.insert_message(&msg).expect("Failed to insert message");
}
let branches = db
.get_session_branch_history(session.id)
.expect("Failed to get branch history");
assert_eq!(
branches,
vec!["main", "feat/auth", "main"],
"Should show branch transitions without consecutive duplicates"
);
}
#[test]
fn test_get_session_branch_history_with_none_branches() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let mut msg1 = create_test_message(session.id, 0, MessageRole::User, "test");
msg1.git_branch = Some("main".to_string());
db.insert_message(&msg1).expect("Failed to insert message");
let mut msg2 = create_test_message(session.id, 1, MessageRole::Assistant, "test");
msg2.git_branch = None; db.insert_message(&msg2).expect("Failed to insert message");
let mut msg3 = create_test_message(session.id, 2, MessageRole::User, "test");
msg3.git_branch = Some("feat/new".to_string());
db.insert_message(&msg3).expect("Failed to insert message");
let branches = db
.get_session_branch_history(session.id)
.expect("Failed to get branch history");
assert_eq!(
branches,
vec!["main", "feat/new"],
"Should skip None branches and show transitions"
);
}
#[test]
fn test_get_session_branch_history_all_none_branches() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
for i in 0..3 {
let mut msg = create_test_message(session.id, i, MessageRole::User, "test");
msg.git_branch = None;
db.insert_message(&msg).expect("Failed to insert message");
}
let branches = db
.get_session_branch_history(session.id)
.expect("Failed to get branch history");
assert!(
branches.is_empty(),
"Session with all None branches should return empty"
);
}
#[test]
fn test_session_stores_machine_id() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Failed to insert session");
let retrieved = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(
retrieved.machine_id,
Some("test-machine".to_string()),
"Machine ID should be preserved"
);
}
#[test]
fn test_session_with_none_machine_id() {
let (db, _dir) = create_test_db();
let mut session = create_test_session("claude-code", "/project", Utc::now(), None);
session.machine_id = None;
db.insert_session(&session)
.expect("Failed to insert session");
let retrieved = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert!(
retrieved.machine_id.is_none(),
"Session with None machine_id should preserve None"
);
}
#[test]
fn test_migration_adds_machine_id_column() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session)
.expect("Should insert session with machine_id column");
let retrieved = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(
retrieved.machine_id,
Some("test-machine".to_string()),
"Machine ID should be stored and retrieved"
);
}
#[test]
fn test_list_sessions_includes_machine_id() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut session1 = create_test_session("claude-code", "/project1", now, None);
session1.machine_id = Some("machine-a".to_string());
let mut session2 = create_test_session("claude-code", "/project2", now, None);
session2.machine_id = Some("machine-b".to_string());
db.insert_session(&session1).expect("insert");
db.insert_session(&session2).expect("insert");
let sessions = db.list_sessions(10, None).expect("list");
assert_eq!(sessions.len(), 2);
let machine_ids: Vec<Option<String>> =
sessions.iter().map(|s| s.machine_id.clone()).collect();
assert!(machine_ids.contains(&Some("machine-a".to_string())));
assert!(machine_ids.contains(&Some("machine-b".to_string())));
}
#[test]
fn test_insert_and_get_annotations() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let annotation = Annotation {
id: Uuid::new_v4(),
session_id: session.id,
content: "This is a test note".to_string(),
created_at: Utc::now(),
};
db.insert_annotation(&annotation)
.expect("insert annotation");
let annotations = db.get_annotations(&session.id).expect("get annotations");
assert_eq!(annotations.len(), 1);
assert_eq!(annotations[0].content, "This is a test note");
assert_eq!(annotations[0].session_id, session.id);
}
#[test]
fn test_delete_annotation() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let annotation = Annotation {
id: Uuid::new_v4(),
session_id: session.id,
content: "Test annotation".to_string(),
created_at: Utc::now(),
};
db.insert_annotation(&annotation).expect("insert");
let deleted = db.delete_annotation(&annotation.id).expect("delete");
assert!(deleted);
let annotations = db.get_annotations(&session.id).expect("get");
assert!(annotations.is_empty());
}
#[test]
fn test_delete_annotations_by_session() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
for i in 0..3 {
let annotation = Annotation {
id: Uuid::new_v4(),
session_id: session.id,
content: format!("Annotation {i}"),
created_at: Utc::now(),
};
db.insert_annotation(&annotation).expect("insert");
}
let count = db
.delete_annotations_by_session(&session.id)
.expect("delete all");
assert_eq!(count, 3);
let annotations = db.get_annotations(&session.id).expect("get");
assert!(annotations.is_empty());
}
#[test]
fn test_insert_and_get_tags() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let tag = Tag {
id: Uuid::new_v4(),
session_id: session.id,
label: "bug-fix".to_string(),
created_at: Utc::now(),
};
db.insert_tag(&tag).expect("insert tag");
let tags = db.get_tags(&session.id).expect("get tags");
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].label, "bug-fix");
}
#[test]
fn test_tag_exists() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
assert!(!db.tag_exists(&session.id, "bug-fix").expect("check"));
let tag = Tag {
id: Uuid::new_v4(),
session_id: session.id,
label: "bug-fix".to_string(),
created_at: Utc::now(),
};
db.insert_tag(&tag).expect("insert tag");
assert!(db.tag_exists(&session.id, "bug-fix").expect("check"));
assert!(!db.tag_exists(&session.id, "feature").expect("check other"));
}
#[test]
fn test_delete_tag() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let tag = Tag {
id: Uuid::new_v4(),
session_id: session.id,
label: "wip".to_string(),
created_at: Utc::now(),
};
db.insert_tag(&tag).expect("insert tag");
let deleted = db.delete_tag(&session.id, "wip").expect("delete");
assert!(deleted);
let deleted_again = db.delete_tag(&session.id, "wip").expect("delete again");
assert!(!deleted_again);
}
#[test]
fn test_list_sessions_with_tag() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session1 = create_test_session("claude-code", "/project1", now, None);
let session2 =
create_test_session("claude-code", "/project2", now - Duration::minutes(5), None);
let session3 = create_test_session(
"claude-code",
"/project3",
now - Duration::minutes(10),
None,
);
db.insert_session(&session1).expect("insert");
db.insert_session(&session2).expect("insert");
db.insert_session(&session3).expect("insert");
let tag1 = Tag {
id: Uuid::new_v4(),
session_id: session1.id,
label: "feature".to_string(),
created_at: Utc::now(),
};
let tag3 = Tag {
id: Uuid::new_v4(),
session_id: session3.id,
label: "feature".to_string(),
created_at: Utc::now(),
};
db.insert_tag(&tag1).expect("insert tag");
db.insert_tag(&tag3).expect("insert tag");
let sessions = db.list_sessions_with_tag("feature", 10).expect("list");
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].id, session1.id);
assert_eq!(sessions[1].id, session3.id);
let sessions = db.list_sessions_with_tag("nonexistent", 10).expect("list");
assert!(sessions.is_empty());
}
#[test]
fn test_get_most_recent_session_for_directory() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let session1 = create_test_session(
"claude-code",
"/home/user/project",
now - Duration::hours(1),
None,
);
let session2 = create_test_session("claude-code", "/home/user/project", now, None);
let session3 = create_test_session("claude-code", "/home/user/other", now, None);
db.insert_session(&session1).expect("insert");
db.insert_session(&session2).expect("insert");
db.insert_session(&session3).expect("insert");
let result = db
.get_most_recent_session_for_directory("/home/user/project")
.expect("get");
assert!(result.is_some());
assert_eq!(result.unwrap().id, session2.id);
let result = db
.get_most_recent_session_for_directory("/home/user/nonexistent")
.expect("get");
assert!(result.is_none());
}
#[test]
fn test_session_deletion_removes_annotations_and_tags() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let annotation = Annotation {
id: Uuid::new_v4(),
session_id: session.id,
content: "Test annotation".to_string(),
created_at: Utc::now(),
};
db.insert_annotation(&annotation).expect("insert");
let tag = Tag {
id: Uuid::new_v4(),
session_id: session.id,
label: "test-tag".to_string(),
created_at: Utc::now(),
};
db.insert_tag(&tag).expect("insert");
db.delete_session(&session.id).expect("delete");
let annotations = db.get_annotations(&session.id).expect("get");
assert!(annotations.is_empty());
let tags = db.get_tags(&session.id).expect("get");
assert!(tags.is_empty());
}
#[test]
fn test_insert_and_get_summary() {
let (db, _dir) = create_test_db();
let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let summary = Summary {
id: Uuid::new_v4(),
session_id: session.id,
content: "Test summary content".to_string(),
generated_at: Utc::now(),
};
db.insert_summary(&summary).expect("insert summary");
let retrieved = db.get_summary(&session.id).expect("get summary");
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.content, "Test summary content");
assert_eq!(retrieved.session_id, session.id);
}
#[test]
fn test_get_summary_nonexistent() {
let (db, _dir) = create_test_db();
let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let retrieved = db.get_summary(&session.id).expect("get summary");
assert!(retrieved.is_none());
}
#[test]
fn test_update_summary() {
let (db, _dir) = create_test_db();
let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let summary = Summary {
id: Uuid::new_v4(),
session_id: session.id,
content: "Original content".to_string(),
generated_at: Utc::now(),
};
db.insert_summary(&summary).expect("insert summary");
let updated = db
.update_summary(&session.id, "Updated content")
.expect("update summary");
assert!(updated);
let retrieved = db.get_summary(&session.id).expect("get summary");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().content, "Updated content");
}
#[test]
fn test_update_summary_nonexistent() {
let (db, _dir) = create_test_db();
let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let updated = db
.update_summary(&session.id, "New content")
.expect("update summary");
assert!(!updated);
}
#[test]
fn test_delete_summary() {
let (db, _dir) = create_test_db();
let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let summary = Summary {
id: Uuid::new_v4(),
session_id: session.id,
content: "To be deleted".to_string(),
generated_at: Utc::now(),
};
db.insert_summary(&summary).expect("insert summary");
let deleted = db.delete_summary(&session.id).expect("delete summary");
assert!(deleted);
let retrieved = db.get_summary(&session.id).expect("get summary");
assert!(retrieved.is_none());
}
#[test]
fn test_delete_session_removes_summary() {
let (db, _dir) = create_test_db();
let session = create_test_session("test-tool", "/test/path", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let summary = Summary {
id: Uuid::new_v4(),
session_id: session.id,
content: "Session summary".to_string(),
generated_at: Utc::now(),
};
db.insert_summary(&summary).expect("insert summary");
db.delete_session(&session.id).expect("delete session");
let retrieved = db.get_summary(&session.id).expect("get summary");
assert!(retrieved.is_none());
}
#[test]
fn test_upsert_machine_insert() {
let (db, _dir) = create_test_db();
let machine = Machine {
id: "test-uuid-1234".to_string(),
name: "my-laptop".to_string(),
created_at: Utc::now().to_rfc3339(),
};
db.upsert_machine(&machine)
.expect("Failed to upsert machine");
let retrieved = db
.get_machine("test-uuid-1234")
.expect("Failed to get machine")
.expect("Machine should exist");
assert_eq!(retrieved.id, "test-uuid-1234");
assert_eq!(retrieved.name, "my-laptop");
}
#[test]
fn test_upsert_machine_update() {
let (db, _dir) = create_test_db();
let machine1 = Machine {
id: "test-uuid-5678".to_string(),
name: "old-name".to_string(),
created_at: Utc::now().to_rfc3339(),
};
db.upsert_machine(&machine1)
.expect("Failed to upsert machine");
let machine2 = Machine {
id: "test-uuid-5678".to_string(),
name: "new-name".to_string(),
created_at: Utc::now().to_rfc3339(),
};
db.upsert_machine(&machine2)
.expect("Failed to upsert machine");
let retrieved = db
.get_machine("test-uuid-5678")
.expect("Failed to get machine")
.expect("Machine should exist");
assert_eq!(retrieved.name, "new-name");
}
#[test]
fn test_get_machine() {
let (db, _dir) = create_test_db();
let not_found = db.get_machine("nonexistent-uuid").expect("Failed to query");
assert!(not_found.is_none(), "Machine should not exist");
let machine = Machine {
id: "existing-uuid".to_string(),
name: "test-machine".to_string(),
created_at: Utc::now().to_rfc3339(),
};
db.upsert_machine(&machine).expect("Failed to upsert");
let found = db
.get_machine("existing-uuid")
.expect("Failed to query")
.expect("Machine should exist");
assert_eq!(found.id, "existing-uuid");
assert_eq!(found.name, "test-machine");
}
#[test]
fn test_get_machine_name_found() {
let (db, _dir) = create_test_db();
let machine = Machine {
id: "uuid-for-name-test".to_string(),
name: "my-workstation".to_string(),
created_at: Utc::now().to_rfc3339(),
};
db.upsert_machine(&machine).expect("Failed to upsert");
let name = db
.get_machine_name("uuid-for-name-test")
.expect("Failed to get name");
assert_eq!(name, "my-workstation");
}
#[test]
fn test_get_machine_name_not_found() {
let (db, _dir) = create_test_db();
let name = db
.get_machine_name("abc123def456789")
.expect("Failed to get name");
assert_eq!(name, "abc123de", "Should return first 8 characters");
let short_name = db.get_machine_name("short").expect("Failed to get name");
assert_eq!(
short_name, "short",
"Should return full ID if shorter than 8 chars"
);
}
#[test]
fn test_list_machines() {
let (db, _dir) = create_test_db();
let machines = db.list_machines().expect("Failed to list");
assert!(machines.is_empty(), "Should have no machines initially");
let machine1 = Machine {
id: "uuid-1".to_string(),
name: "machine-1".to_string(),
created_at: "2024-01-01T00:00:00Z".to_string(),
};
let machine2 = Machine {
id: "uuid-2".to_string(),
name: "machine-2".to_string(),
created_at: "2024-01-02T00:00:00Z".to_string(),
};
db.upsert_machine(&machine1).expect("Failed to upsert");
db.upsert_machine(&machine2).expect("Failed to upsert");
let machines = db.list_machines().expect("Failed to list");
assert_eq!(machines.len(), 2, "Should have 2 machines");
assert_eq!(machines[0].id, "uuid-1");
assert_eq!(machines[1].id, "uuid-2");
}
#[test]
fn test_find_session_by_id_prefix_full_uuid() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let found = db
.find_session_by_id_prefix(&session.id.to_string())
.expect("find session")
.expect("session should exist");
assert_eq!(found.id, session.id, "Should find session by full UUID");
}
#[test]
fn test_find_session_by_id_prefix_short_prefix() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let prefix = &session.id.to_string()[..8];
let found = db
.find_session_by_id_prefix(prefix)
.expect("find session")
.expect("session should exist");
assert_eq!(found.id, session.id, "Should find session by short prefix");
}
#[test]
fn test_find_session_by_id_prefix_very_short_prefix() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let prefix = &session.id.to_string()[..4];
let found = db
.find_session_by_id_prefix(prefix)
.expect("find session")
.expect("session should exist");
assert_eq!(
found.id, session.id,
"Should find session by very short prefix"
);
}
#[test]
fn test_find_session_by_id_prefix_not_found() {
let (db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
let found = db
.find_session_by_id_prefix("zzz999")
.expect("find session");
assert!(
found.is_none(),
"Should return None for non-matching prefix"
);
}
#[test]
fn test_find_session_by_id_prefix_empty_db() {
let (db, _dir) = create_test_db();
let found = db
.find_session_by_id_prefix("abc123")
.expect("find session");
assert!(found.is_none(), "Should return None for empty database");
}
#[test]
fn test_find_session_by_id_prefix_ambiguous() {
let (db, _dir) = create_test_db();
let mut sessions = Vec::new();
for _ in 0..100 {
let session = create_test_session("claude-code", "/project", Utc::now(), None);
db.insert_session(&session).expect("insert session");
sessions.push(session);
}
let first_session = &sessions[0];
let first_char = first_session.id.to_string().chars().next().unwrap();
let matching_count = sessions
.iter()
.filter(|s| s.id.to_string().starts_with(first_char))
.count();
if matching_count > 1 {
let result = db.find_session_by_id_prefix(&first_char.to_string());
assert!(
result.is_err(),
"Should return error for ambiguous single-character prefix"
);
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Ambiguous"),
"Error should mention ambiguity"
);
}
}
#[test]
fn test_find_session_by_id_prefix_returns_correct_session_data() {
let (db, _dir) = create_test_db();
let mut session =
create_test_session("claude-code", "/home/user/myproject", Utc::now(), None);
session.tool_version = Some("2.0.0".to_string());
session.model = Some("claude-opus-4".to_string());
session.git_branch = Some("feature/test".to_string());
session.message_count = 42;
db.insert_session(&session).expect("insert session");
let prefix = &session.id.to_string()[..8];
let found = db
.find_session_by_id_prefix(prefix)
.expect("find session")
.expect("session should exist");
assert_eq!(found.id, session.id);
assert_eq!(found.tool, "claude-code");
assert_eq!(found.tool_version, Some("2.0.0".to_string()));
assert_eq!(found.model, Some("claude-opus-4".to_string()));
assert_eq!(found.working_directory, "/home/user/myproject");
assert_eq!(found.git_branch, Some("feature/test".to_string()));
assert_eq!(found.message_count, 42);
}
#[test]
fn test_find_session_by_id_prefix_many_sessions() {
let (db, _dir) = create_test_db();
let mut target_session = None;
for i in 0..200 {
let session =
create_test_session("claude-code", &format!("/project/{i}"), Utc::now(), None);
db.insert_session(&session).expect("insert session");
if i == 150 {
target_session = Some(session);
}
}
let target = target_session.expect("should have target session");
let prefix = &target.id.to_string()[..8];
let found = db
.find_session_by_id_prefix(prefix)
.expect("find session")
.expect("session should exist");
assert_eq!(
found.id, target.id,
"Should find correct session among many"
);
assert_eq!(found.working_directory, "/project/150");
}
#[test]
fn test_import_session_with_messages() {
let (mut db, _dir) = create_test_db();
let session = create_test_session("claude-code", "/home/user/project", Utc::now(), None);
let messages = vec![
create_test_message(session.id, 0, MessageRole::User, "Hello"),
create_test_message(session.id, 1, MessageRole::Assistant, "Hi there!"),
create_test_message(session.id, 2, MessageRole::User, "How are you?"),
];
let synced_at = Utc::now();
db.import_session_with_messages(&session, &messages, Some(synced_at))
.expect("Failed to import session with messages");
let retrieved_session = db.get_session(&session.id).expect("Failed to get session");
assert!(retrieved_session.is_some(), "Session should exist");
let retrieved_session = retrieved_session.unwrap();
assert_eq!(retrieved_session.tool, "claude-code");
let retrieved_messages = db
.get_messages(&session.id)
.expect("Failed to get messages");
assert_eq!(retrieved_messages.len(), 3, "Should have 3 messages");
assert_eq!(retrieved_messages[0].content.text(), "Hello");
assert_eq!(retrieved_messages[1].content.text(), "Hi there!");
assert_eq!(retrieved_messages[2].content.text(), "How are you?");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert!(
!unsynced.iter().any(|s| s.id == session.id),
"Session should be marked as synced"
);
}
#[test]
fn test_import_session_with_messages_no_sync() {
let (mut db, _dir) = create_test_db();
let session = create_test_session("aider", "/tmp/test", Utc::now(), None);
let messages = vec![create_test_message(
session.id,
0,
MessageRole::User,
"Test message",
)];
db.import_session_with_messages(&session, &messages, None)
.expect("Failed to import session");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert!(
unsynced.iter().any(|s| s.id == session.id),
"Session should NOT be marked as synced"
);
}
#[test]
fn test_session_update_resets_sync_status() {
let (db, _dir) = create_test_db();
let mut session =
create_test_session("claude-code", "/home/user/project", Utc::now(), None);
session.message_count = 5;
db.insert_session(&session)
.expect("Failed to insert session");
db.mark_sessions_synced(&[session.id], Utc::now())
.expect("Failed to mark synced");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert!(
!unsynced.iter().any(|s| s.id == session.id),
"Session should be synced initially"
);
session.message_count = 10;
session.ended_at = Some(Utc::now());
db.insert_session(&session)
.expect("Failed to update session");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert!(
unsynced.iter().any(|s| s.id == session.id),
"Session should be marked for re-sync after update"
);
let retrieved = db
.get_session(&session.id)
.expect("Failed to get session")
.expect("Session should exist");
assert_eq!(
retrieved.message_count, 10,
"Message count should be updated"
);
}
#[test]
fn test_clear_sync_status_all_sessions() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None);
let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None);
let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None);
db.insert_session(&session1)
.expect("Failed to insert session1");
db.insert_session(&session2)
.expect("Failed to insert session2");
db.insert_session(&session3)
.expect("Failed to insert session3");
db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now())
.expect("Failed to mark synced");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert_eq!(unsynced.len(), 0, "All sessions should be synced");
let count = db.clear_sync_status().expect("Failed to clear sync status");
assert_eq!(count, 3, "Should have cleared 3 sessions");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert_eq!(unsynced.len(), 3, "All sessions should be unsynced now");
}
#[test]
fn test_clear_sync_status_for_specific_sessions() {
let (db, _dir) = create_test_db();
let session1 = create_test_session("claude-code", "/home/user/project1", Utc::now(), None);
let session2 = create_test_session("aider", "/home/user/project2", Utc::now(), None);
let session3 = create_test_session("cline", "/home/user/project3", Utc::now(), None);
db.insert_session(&session1)
.expect("Failed to insert session1");
db.insert_session(&session2)
.expect("Failed to insert session2");
db.insert_session(&session3)
.expect("Failed to insert session3");
db.mark_sessions_synced(&[session1.id, session2.id, session3.id], Utc::now())
.expect("Failed to mark synced");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert_eq!(unsynced.len(), 0, "All sessions should be synced");
let count = db
.clear_sync_status_for_sessions(&[session1.id, session3.id])
.expect("Failed to clear sync status");
assert_eq!(count, 2, "Should have cleared 2 sessions");
let unsynced = db.get_unsynced_sessions().expect("Failed to get unsynced");
assert_eq!(unsynced.len(), 2, "Two sessions should be unsynced");
assert!(
unsynced.iter().any(|s| s.id == session1.id),
"session1 should be unsynced"
);
assert!(
!unsynced.iter().any(|s| s.id == session2.id),
"session2 should still be synced"
);
assert!(
unsynced.iter().any(|s| s.id == session3.id),
"session3 should be unsynced"
);
}
#[test]
fn test_clear_sync_status_for_sessions_empty_list() {
let (db, _dir) = create_test_db();
let count = db
.clear_sync_status_for_sessions(&[])
.expect("Failed to clear sync status");
assert_eq!(count, 0, "Should return 0 for empty list");
}
#[test]
fn test_clear_sync_status_for_nonexistent_session() {
let (db, _dir) = create_test_db();
let fake_id = Uuid::new_v4();
let count = db
.clear_sync_status_for_sessions(&[fake_id])
.expect("Failed to clear sync status");
assert_eq!(count, 0, "Should return 0 for nonexistent session");
}
#[test]
fn test_sessions_in_date_range_all() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(3), None);
let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let results = db
.sessions_in_date_range(None, None, None)
.expect("Failed to query sessions");
assert_eq!(
results.len(),
2,
"Should return all sessions when no filters"
);
}
#[test]
fn test_sessions_in_date_range_with_since() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let since = now - Duration::hours(3);
let results = db
.sessions_in_date_range(Some(since), None, None)
.expect("Failed to query sessions");
assert_eq!(results.len(), 1, "Should return only sessions after since");
assert_eq!(results[0].id, s2.id, "Should return the newer session");
}
#[test]
fn test_sessions_in_date_range_with_working_dir() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session(
"claude-code",
"/project/alpha",
now - Duration::hours(2),
None,
);
let s2 = create_test_session("aider", "/project/beta", now - Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let results = db
.sessions_in_date_range(None, None, Some("/project/alpha"))
.expect("Failed to query sessions");
assert_eq!(results.len(), 1, "Should return only matching working dir");
assert_eq!(results[0].id, s1.id, "Should return the alpha session");
}
#[test]
fn test_sessions_in_date_range_with_until() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session("claude-code", "/project/a", now - Duration::hours(5), None);
let s2 = create_test_session("aider", "/project/b", now - Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let until = now - Duration::hours(3);
let results = db
.sessions_in_date_range(None, Some(until), None)
.expect("Failed to query sessions");
assert_eq!(results.len(), 1, "Should return only sessions before until");
assert_eq!(results[0].id, s1.id, "Should return the older session");
}
#[test]
fn test_sessions_in_date_range_with_since_and_until() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session("claude-code", "/project", now - Duration::hours(8), None);
let s2 = create_test_session("aider", "/project", now - Duration::hours(4), None);
let s3 = create_test_session("claude-code", "/project", now - Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
db.insert_session(&s3).expect("Failed to insert session");
let since = now - Duration::hours(6);
let until = now - Duration::hours(2);
let results = db
.sessions_in_date_range(Some(since), Some(until), None)
.expect("Failed to query sessions");
assert_eq!(
results.len(),
1,
"Should return only sessions in the window"
);
assert_eq!(results[0].id, s2.id, "Should return the middle session");
}
#[test]
fn test_average_session_duration() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
s1.ended_at = Some(s1.started_at + Duration::minutes(30));
let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
s2.ended_at = Some(s2.started_at + Duration::minutes(60));
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let avg = db
.average_session_duration_minutes(None, None)
.expect("Failed to get average duration");
assert!(avg.is_some(), "Should return an average");
let avg_val = avg.unwrap();
assert!(
(avg_val - 45.0).abs() < 1.0,
"Average should be approximately 45 minutes, got {}",
avg_val
);
}
#[test]
fn test_average_session_duration_no_ended_sessions() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session("claude-code", "/project", now, None);
db.insert_session(&s1).expect("Failed to insert session");
let avg = db
.average_session_duration_minutes(None, None)
.expect("Failed to get average duration");
assert!(
avg.is_none(),
"Should return None when no sessions have ended_at"
);
}
#[test]
fn test_sessions_by_tool_in_range() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let s1 = create_test_session("claude-code", "/project", now - Duration::hours(3), None);
let s2 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
let s3 = create_test_session("aider", "/project", now - Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
db.insert_session(&s3).expect("Failed to insert session");
let results = db
.sessions_by_tool_in_range(None, None)
.expect("Failed to get sessions by tool");
assert_eq!(results.len(), 2, "Should have two tools");
assert_eq!(results[0].0, "claude-code");
assert_eq!(results[0].1, 2);
assert_eq!(results[1].0, "aider");
assert_eq!(results[1].1, 1);
}
#[test]
fn test_sessions_by_weekday() {
let (db, _dir) = create_test_db();
let monday = chrono::NaiveDate::from_ymd_opt(2024, 1, 15)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
.and_utc();
let s1 = create_test_session("claude-code", "/project", monday, None);
let s2 = create_test_session("aider", "/project", monday + Duration::hours(1), None);
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let results = db
.sessions_by_weekday(None, None)
.expect("Failed to get sessions by weekday");
assert_eq!(results.len(), 1, "Should have one weekday entry");
assert_eq!(results[0].0, 1, "Monday is weekday 1");
assert_eq!(results[0].1, 2, "Should have 2 sessions on Monday");
}
#[test]
fn test_average_message_count() {
let (db, _dir) = create_test_db();
let now = Utc::now();
let mut s1 = create_test_session("claude-code", "/project", now - Duration::hours(2), None);
s1.message_count = 10;
let mut s2 = create_test_session("aider", "/project", now - Duration::hours(1), None);
s2.message_count = 20;
db.insert_session(&s1).expect("Failed to insert session");
db.insert_session(&s2).expect("Failed to insert session");
let avg = db
.average_message_count(None, None)
.expect("Failed to get average message count");
assert!(avg.is_some(), "Should return an average");
let avg_val = avg.unwrap();
assert!(
(avg_val - 15.0).abs() < 0.01,
"Average should be 15.0, got {}",
avg_val
);
}
}