ccd-cli 1.0.0-beta.1

Bootstrap and validate Continuous Context Development repositories
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde_json::Value;

use super::{number_key, string_key};
use crate::telemetry::host::{self as host_telemetry, HostContextSnapshot};

const GEMINI_CONTEXT_WINDOW_VARS: &[&str] = &[
    "CCD_GEMINI_CONTEXT_WINDOW_TOKENS",
    "GEMINI_CONTEXT_WINDOW_TOKENS",
];

#[derive(Default)]
struct GeminiSessionSnapshot {
    latest_prompt_tokens: Option<u64>,
    input_tokens: u64,
    output_tokens: u64,
    cache_read_input_tokens: u64,
    context_window: Option<u64>,
    compacted: bool,
    model_name: Option<String>,
}

pub(crate) fn current(repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
    let telemetry_path = match telemetry_path(repo_root)? {
        Some(path) => path,
        None => return Ok(None),
    };

    let observed_at_epoch_s = match host_telemetry::file_mtime_epoch_s(&telemetry_path)? {
        Some(epoch_s) => epoch_s,
        None => return Ok(None),
    };
    if host_telemetry::env_string(&["CCD_GEMINI_TELEMETRY_PATH", "GEMINI_TELEMETRY_OUTFILE"])
        .is_none()
        && !host_telemetry::is_recent(observed_at_epoch_s)?
    {
        return Ok(None);
    }

    let file = File::open(&telemetry_path)
        .with_context(|| format!("failed to open {}", telemetry_path.display()))?;
    let reader = BufReader::new(file);
    let mut sessions: HashMap<String, GeminiSessionSnapshot> = HashMap::new();
    let mut latest_session_key = None;

    for line in reader.lines() {
        let line = line.with_context(|| format!("failed to read {}", telemetry_path.display()))?;
        if line.trim().is_empty() {
            continue;
        }

        let value: Value = match serde_json::from_str(&line) {
            Ok(value) => value,
            Err(_) => continue,
        };

        let event_name = event_name(&value).unwrap_or_else(|| {
            if line.contains("gemini_cli.api_response") {
                "gemini_cli.api_response".to_owned()
            } else if line.contains("gemini_cli.chat_compression") {
                "gemini_cli.chat_compression".to_owned()
            } else {
                String::new()
            }
        });
        if event_name.is_empty() {
            continue;
        }

        let session_key =
            string_key(&value, &["session_id", "sessionId"]).unwrap_or_else(|| "global".to_owned());
        latest_session_key = Some(session_key.clone());
        let session = sessions.entry(session_key).or_default();

        if event_name.contains("gemini_cli.api_response") {
            if let Some(prompt_tokens) = prompt_tokens(&value) {
                session.latest_prompt_tokens = Some(prompt_tokens);
            }
            if let Some(input_tokens) = input_tokens(&value) {
                session.input_tokens = session.input_tokens.saturating_add(input_tokens);
            }
            if let Some(output_tokens) = output_tokens(&value) {
                session.output_tokens = session.output_tokens.saturating_add(output_tokens);
            }
            if let Some(cache_read_tokens) = cache_read_input_tokens(&value) {
                session.cache_read_input_tokens = session
                    .cache_read_input_tokens
                    .saturating_add(cache_read_tokens);
            }
            session.context_window = number_key(
                &value,
                &[
                    "model_context_window",
                    "context_window",
                    "contextWindow",
                    "limit_context",
                ],
            )
            .or_else(|| host_telemetry::env_u64(GEMINI_CONTEXT_WINDOW_VARS));
            if let Some(model_name) =
                string_key(&value, &["model", "model_name", "modelName", "model_id"])
            {
                session.model_name = Some(model_name);
            }
        }

        if event_name.contains("gemini_cli.chat_compression") {
            session.compacted = true;
        }
    }

    let Some(session_key) = latest_session_key else {
        return Ok(None);
    };
    let Some(session) = sessions.remove(&session_key) else {
        return Ok(None);
    };
    if session.latest_prompt_tokens.is_none() && !session.compacted {
        return Ok(None);
    }

    Ok(Some(HostContextSnapshot {
        host: "gemini",
        observed_at_epoch_s,
        model_name: session
            .model_name
            .or_else(|| host_telemetry::env_string(&["CCD_GEMINI_MODEL", "GEMINI_MODEL"]))
            .or_else(|| host_telemetry::env_string(&["CCD_HOST_MODEL"])),
        context_used_pct: session
            .latest_prompt_tokens
            .and_then(|total| host_telemetry::compute_pct(total, session.context_window)),
        total_tokens: session.latest_prompt_tokens,
        model_context_window: session.context_window,
        compacted: session.compacted.then_some(true),
        cost_usage: host_telemetry::HostCostUsage {
            input_tokens: session.input_tokens,
            output_tokens: session.output_tokens,
            cache_creation_input_tokens: 0,
            cache_read_input_tokens: session.cache_read_input_tokens,
            blended_total_tokens: None,
        },
    }))
}

fn telemetry_path(repo_root: &Path) -> Result<Option<PathBuf>> {
    if let Some(path) =
        host_telemetry::env_string(&["CCD_GEMINI_TELEMETRY_PATH", "GEMINI_TELEMETRY_OUTFILE"])
    {
        let path = PathBuf::from(path);
        return Ok(path.is_file().then_some(path));
    }

    let candidates = [
        repo_root.join(".gemini/telemetry.log"),
        repo_root.join("telemetry.log"),
        host_telemetry::home_dir()?.join(".gemini/telemetry.log"),
    ];

    Ok(candidates.into_iter().find(|path| path.is_file()))
}

fn event_name(value: &Value) -> Option<String> {
    string_key(value, &["name", "event_name", "eventName"])
}

fn prompt_tokens(value: &Value) -> Option<u64> {
    let input_tokens = input_tokens(value).unwrap_or(0);
    let cached_tokens = cache_read_input_tokens(value).unwrap_or(0);

    let total = input_tokens.saturating_add(cached_tokens);
    (total > 0).then_some(total)
}

fn input_tokens(value: &Value) -> Option<u64> {
    number_key(
        value,
        &[
            "input_token_count",
            "inputTokens",
            "prompt_tokens",
            "promptTokenCount",
        ],
    )
}

fn output_tokens(value: &Value) -> Option<u64> {
    number_key(
        value,
        &[
            "output_token_count",
            "outputTokens",
            "completion_tokens",
            "completionTokenCount",
        ],
    )
}

fn cache_read_input_tokens(value: &Value) -> Option<u64> {
    number_key(
        value,
        &[
            "cached_content_token_count",
            "cacheReadInputTokens",
            "cachedTokens",
        ],
    )
}