use crate::session::Session;
use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags, params};
use std::{collections::HashMap, path::PathBuf};
#[derive(Clone, Copy, Debug, Default)]
pub struct Options {
pub include_children: bool,
}
#[derive(Debug)]
pub struct Diagnosis {
pub path: PathBuf,
pub sessions: Option<usize>,
pub error: Option<String>,
}
pub fn load(paths: &[PathBuf], limit: usize, options: Options) -> Vec<Session> {
let mut map: HashMap<String, Session> = HashMap::new();
for path in paths {
let Ok(list) = query(path, limit, options) else {
continue;
};
for session in list {
match map.get(&session.id) {
Some(old) if old.updated >= session.updated => {}
_ => {
map.insert(session.id.clone(), session);
}
}
}
}
let mut list: Vec<_> = map.into_values().collect();
list.sort_by(|a, b| b.updated.cmp(&a.updated).then_with(|| b.id.cmp(&a.id)));
list.truncate(limit);
list
}
pub fn load_one(paths: &[PathBuf], id: &str, options: Options) -> Result<Session> {
for path in paths {
if let Some(session) = query_one(path, id, options)? {
return Ok(session);
}
}
anyhow::bail!("session not found: {id}")
}
pub fn diagnose(paths: &[PathBuf], options: Options) -> Vec<Diagnosis> {
paths
.iter()
.map(|path| match query(path, usize::MAX, options) {
Ok(sessions) => Diagnosis {
path: path.clone(),
sessions: Some(sessions.len()),
error: None,
},
Err(err) => Diagnosis {
path: path.clone(),
sessions: None,
error: Some(err.to_string()),
},
})
.collect()
}
fn query(path: &PathBuf, limit: usize, options: Options) -> Result<Vec<Session>> {
query_inner(path, Some(limit), None, options)
}
fn query_one(path: &PathBuf, id: &str, options: Options) -> Result<Option<Session>> {
Ok(query_inner(path, None, Some(id), options)?
.into_iter()
.next())
}
fn query_inner(
path: &PathBuf,
limit: Option<usize>,
id: Option<&str>,
options: Options,
) -> Result<Vec<Session>> {
let conn = Connection::open_with_flags(
path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)
.with_context(|| format!("open {}", path.display()))?;
if !has_table(&conn, "session")? {
return Ok(Vec::new());
}
let agent = has_col(&conn, "session", "agent")?;
let model = has_col(&conn, "session", "model")?;
let path_col = has_col(&conn, "session", "path")?;
let archived = has_col(&conn, "session", "time_archived")?;
let parent = has_col(&conn, "session", "parent_id")?;
let project = has_table(&conn, "project")?
&& has_col(&conn, "session", "project_id")?
&& has_col(&conn, "project", "id")?
&& has_col(&conn, "project", "name")?
&& has_col(&conn, "project", "worktree")?;
let part = has_table(&conn, "part")?;
let mut where_clause = Vec::new();
if archived {
where_clause.push("s.time_archived is null");
}
if parent && !options.include_children {
where_clause.push("s.parent_id is null");
}
if id.is_some() {
where_clause.push("s.id = ?1");
}
let where_clause = if where_clause.is_empty() {
String::new()
} else {
format!("where {}", where_clause.join(" and "))
};
let limit = limit.unwrap_or(1);
let sql = format!(
"select s.id, s.title, s.directory, {}, {}, {}, s.time_updated, s.time_created, {}, {} from session s {} {} order by s.time_updated desc, s.id desc limit {}",
if path_col { "s.path" } else { "null" },
if agent { "s.agent" } else { "null" },
if model { "s.model" } else { "null" },
if project { "p.name" } else { "null" },
if project { "p.worktree" } else { "null" },
if project {
"left join project p on p.id = s.project_id"
} else {
""
},
where_clause,
limit,
);
let mut stmt = conn.prepare(&sql)?;
let build = |row: &rusqlite::Row<'_>| {
let id: String = row.get(0)?;
Ok(Session {
preview: preview(&conn, part, &id).ok().flatten(),
id,
title: row.get(1)?,
directory: PathBuf::from(row.get::<_, String>(2)?),
path: row.get(3)?,
agent: row.get(4)?,
model: row.get(5)?,
updated: row.get(6)?,
created: row.get(7)?,
project: row.get(8)?,
worktree: row.get::<_, Option<String>>(9)?.map(PathBuf::from),
db: path.clone(),
})
};
let rows = if let Some(id) = id {
stmt.query_map(params![id], build)?
} else {
stmt.query_map([], build)?
};
Ok(rows.filter_map(Result::ok).collect())
}
fn preview(conn: &Connection, part: bool, id: &str) -> Result<Option<String>> {
if !part {
return Ok(None);
}
let mut stmt = conn.prepare(
"select data from part where session_id = ?1 order by time_created desc limit 5",
)?;
for row in stmt.query_map([id], |row| row.get::<_, String>(0))? {
let text = row?.replace(['\n', '\r', '\t'], " ");
if text.contains("\"type\":\"text\"") || text.contains("\"text\"") {
return Ok(Some(trim(&text, 240)));
}
}
Ok(None)
}
fn trim(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
format!("{}...", text.chars().take(max).collect::<String>())
}
fn has_table(conn: &Connection, name: &str) -> Result<bool> {
Ok(conn.query_row(
"select exists(select 1 from sqlite_master where type='table' and name=?1)",
[name],
|row| row.get::<_, i64>(0),
)? == 1)
}
fn has_col(conn: &Connection, table: &str, name: &str) -> Result<bool> {
let mut stmt = conn.prepare(&format!("pragma table_info({table})"))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
Ok(rows.filter_map(Result::ok).any(|col| col == name))
}