oc-session 0.1.0

Global OpenCode session browser and resume tool
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))
}