opencode-stats 1.3.6

A terminal dashboard for OpenCode usage statistics inspired by the /stats command in Claude Code
use std::env;
use std::path::{Path, PathBuf};

use rusqlite::Connection;

use crate::db::errors::{Error, Result};

pub fn default_database_candidates(custom_path: Option<&Path>) -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    if let Some(path) = custom_path {
        candidates.push(path.to_path_buf());
    }

    if let Ok(path) = env::var("OCMONITOR_DATABASE_FILE") {
        candidates.push(PathBuf::from(path));
    }

    if let Some(home) = dirs::home_dir() {
        candidates.push(
            home.join(".local")
                .join("share")
                .join("opencode")
                .join("opencode.db"),
        );
    }

    if cfg!(target_os = "windows") {
        if let Ok(local_app_data) = env::var("LOCALAPPDATA") {
            candidates.push(
                PathBuf::from(local_app_data)
                    .join("opencode")
                    .join("opencode.db"),
            );
        }
        if let Ok(appdata) = env::var("APPDATA") {
            candidates.push(PathBuf::from(appdata).join("opencode").join("opencode.db"));
        }
    } else if cfg!(target_os = "macos") {
        if let Some(home) = dirs::home_dir() {
            candidates.push(
                home.join("Library")
                    .join("Application Support")
                    .join("opencode")
                    .join("opencode.db"),
            );
        }
    } else if let Some(data_dir) = dirs::data_local_dir() {
        candidates.push(data_dir.join("opencode").join("opencode.db"));
    }

    dedupe_preserve_order(candidates)
}

pub fn discover_database_path(custom_path: Option<&Path>) -> Option<PathBuf> {
    default_database_candidates(custom_path)
        .into_iter()
        .find(|candidate| {
            candidate.exists() && database_has_expected_tables(candidate).unwrap_or(false)
        })
}

pub fn open_database(path: &Path) -> Result<Connection> {
    Connection::open(path).map_err(|e| Error::database_open(path, e))
}

pub fn database_has_expected_tables(path: &Path) -> Result<bool> {
    let conn = Connection::open(path).map_err(|e| Error::database_open(path, e))?;

    for table in ["session", "message", "project"] {
        let exists = conn
            .query_row(
                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
                [table],
                |row| row.get::<_, i64>(0),
            )
            .map_err(Error::database_query)?;
        if exists == 0 {
            return Ok(false);
        }
    }

    Ok(true)
}

fn dedupe_preserve_order(paths: Vec<PathBuf>) -> Vec<PathBuf> {
    let mut seen = std::collections::HashSet::new();
    let mut result = Vec::new();
    for path in paths {
        if seen.insert(path.clone()) {
            result.push(path);
        }
    }
    result
}

#[cfg(test)]
mod tests {
    use super::{database_has_expected_tables, default_database_candidates};
    use std::fs;
    use std::path::Path;
    use std::time::{SystemTime, UNIX_EPOCH};

    use rusqlite::Connection;

    #[test]
    fn custom_path_has_priority() {
        let custom = Path::new("custom.db");
        let candidates = default_database_candidates(Some(custom));
        assert_eq!(candidates.first().unwrap(), custom);
    }

    #[test]
    fn rejects_sqlite_without_expected_schema() {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = std::env::temp_dir().join(format!("oc-stats-schema-test-{nonce}.db"));
        let conn = Connection::open(&db_path).unwrap();
        conn.execute("CREATE TABLE only_one(id INTEGER)", [])
            .unwrap();
        drop(conn);

        assert!(!database_has_expected_tables(&db_path).unwrap());
        let _ = fs::remove_file(db_path);
    }
}