terraphim_usage 1.20.3

Usage tracking and analytics for Terraphim AI
Documentation
use crate::{MetricLine, ProgressFormat, ProviderUsage, Result, UsageError, UsageProvider};
use std::path::PathBuf;

pub struct OpenCodeGoProvider {
    db_path: PathBuf,
}

impl OpenCodeGoProvider {
    pub fn new() -> Self {
        let home = std::env::var("HOME").unwrap_or_default();
        Self {
            db_path: PathBuf::from(format!("{}/.local/share/opencode/opencode.db", home)),
        }
    }

    pub fn with_db_path(path: PathBuf) -> Self {
        Self { db_path: path }
    }
}

impl Default for OpenCodeGoProvider {
    fn default() -> Self {
        Self::new()
    }
}

impl OpenCodeGoProvider {
    fn query_sqlite(&self, query: &str) -> std::result::Result<String, std::io::Error> {
        let output = std::process::Command::new("sqlite3")
            .arg("-json")
            .arg(&self.db_path)
            .arg(query)
            .output()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(std::io::Error::other(format!("sqlite3 failed: {}", stderr)));
        }

        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }
}

impl UsageProvider for OpenCodeGoProvider {
    fn id(&self) -> &str {
        "opencode-go"
    }

    fn display_name(&self) -> &str {
        "OpenCode Go"
    }

    fn fetch_usage(
        &self,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ProviderUsage>> + Send + '_>>
    {
        Box::pin(async move {
            if !self.db_path.exists() {
                return Err(UsageError::ProviderNotFound(
                    "opencode-go database not found".to_string(),
                ));
            }

            if which::which("sqlite3").is_err() {
                return Err(UsageError::FetchFailed {
                    provider: "opencode-go".to_string(),
                    source: "sqlite3 CLI not found. Install sqlite3 to query OpenCode Go usage."
                        .into(),
                });
            }

            let query = "SELECT json_extract(data, '$.role') as role, COUNT(*) as count, SUM(CASE WHEN json_extract(data, '$.cost') IS NOT NULL THEN json_extract(data, '$.cost') ELSE 0 END) as total_cost, SUM(json_extract(data, '$.tokens.input')) as input_tokens, SUM(json_extract(data, '$.tokens.output')) as output_tokens FROM message GROUP BY json_extract(data, '$.role')";

            let json_str = self
                .query_sqlite(query)
                .map_err(|e| UsageError::FetchFailed {
                    provider: "opencode-go".to_string(),
                    source: e.into(),
                })?;

            let rows: Vec<serde_json::Value> = serde_json::from_str(&json_str).unwrap_or_default();

            let mut total_cost: f64 = 0.0;
            let mut total_messages: i64 = 0;
            let mut assistant_count: i64 = 0;
            let mut input_tokens: i64 = 0;
            let mut output_tokens: i64 = 0;

            for row in &rows {
                if let Some(cost) = row.get("total_cost").and_then(|v| v.as_f64()) {
                    total_cost += cost;
                }
                if let Some(count) = row.get("count").and_then(|v| v.as_i64()) {
                    total_messages += count;
                }
                if let Some(tokens) = row.get("input_tokens").and_then(|v| v.as_i64()) {
                    input_tokens += tokens;
                }
                if let Some(tokens) = row.get("output_tokens").and_then(|v| v.as_i64()) {
                    output_tokens += tokens;
                }
                if row
                    .get("role")
                    .and_then(|v| v.as_str())
                    .map(|r| r == "assistant")
                    .unwrap_or(false)
                {
                    assistant_count = row.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
                }
            }

            let mut lines = Vec::new();

            lines.push(MetricLine::Progress {
                label: "Total spend".to_string(),
                used: total_cost,
                limit: 50.0,
                format: ProgressFormat::Dollars,
                resets_at: None,
                period_duration_ms: None,
                color: None,
            });

            lines.push(MetricLine::Text {
                label: "Messages".to_string(),
                value: format!(
                    "{} total ({} assistant responses)",
                    total_messages, assistant_count
                ),
                color: None,
                subtitle: None,
            });

            lines.push(MetricLine::Text {
                label: "Tokens".to_string(),
                value: format!("{} in / {} out", input_tokens, output_tokens),
                color: None,
                subtitle: None,
            });

            Ok(ProviderUsage {
                provider_id: "opencode-go".to_string(),
                display_name: "OpenCode Go".to_string(),
                plan: None,
                lines,
                fetched_at: chrono::Utc::now().to_rfc3339(),
            })
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_opencode_go_provider_id() {
        let provider = OpenCodeGoProvider::new();
        assert_eq!(provider.id(), "opencode-go");
    }

    #[test]
    fn test_opencode_go_provider_display_name() {
        let provider = OpenCodeGoProvider::new();
        assert_eq!(provider.display_name(), "OpenCode Go");
    }

    #[test]
    fn test_opencode_go_missing_db() {
        let provider = OpenCodeGoProvider::with_db_path(PathBuf::from("/nonexistent/opencode.db"));
        let rt = tokio::runtime::Runtime::new().unwrap();
        let result = rt.block_on(provider.fetch_usage());
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            matches!(err, UsageError::ProviderNotFound(ref msg) if msg.contains("not found")),
            "Expected ProviderNotFound, got {:?}",
            err
        );
    }
}