ccd-cli 1.0.0-beta.1

Bootstrap and validate Continuous Context Development repositories
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

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

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

const OPENCODE_CONTEXT_WINDOW_VARS: &[&str] = &[
    "CCD_OPENCODE_CONTEXT_WINDOW_TOKENS",
    "OPENCODE_CONTEXT_WINDOW_TOKENS",
];

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

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

    let Some((prompt_tokens, completion_tokens, summary_message_id)) =
        latest_session_metrics(&database_path)?
    else {
        return Ok(None);
    };
    let context_window = opencode_context_window(repo_root)?;
    let model_name = opencode_model_name(repo_root)?
        .or_else(|| host_telemetry::env_string(&["CCD_OPENCODE_MODEL", "OPENCODE_MODEL"]))
        .or_else(|| host_telemetry::env_string(&["CCD_HOST_MODEL"]));

    Ok(Some(HostContextSnapshot {
        host: "opencode",
        observed_at_epoch_s,
        model_name,
        context_used_pct: host_telemetry::compute_pct(prompt_tokens, context_window),
        total_tokens: Some(prompt_tokens),
        model_context_window: context_window,
        compacted: summary_message_id
            .as_deref()
            .filter(|value| !value.is_empty())
            .map(|_| true),
        cost_usage: host_telemetry::HostCostUsage {
            input_tokens: prompt_tokens,
            output_tokens: completion_tokens,
            cache_creation_input_tokens: 0,
            cache_read_input_tokens: 0,
            blended_total_tokens: None,
        },
    }))
}

fn database_path() -> Result<Option<PathBuf>> {
    if let Some(path) = host_telemetry::env_string(&["CCD_OPENCODE_DB_PATH"]) {
        let path = PathBuf::from(path);
        return Ok(path.is_file().then_some(path));
    }

    if !sqlite3_available() {
        return Ok(None);
    }

    let data_dir = match host_telemetry::env_string(&["CCD_OPENCODE_DATA_DIR", "OPENCODE_DATA_DIR"])
    {
        Some(path) => PathBuf::from(path),
        None => host_telemetry::home_dir()?.join(".local/share/opencode"),
    };
    if !data_dir.is_dir() {
        return Ok(None);
    }

    let mut candidates = Vec::new();
    collect_sqlite_candidates(&data_dir, 4, &mut candidates)?;
    let mut newest = None;

    for candidate in candidates {
        if !looks_like_session_db(&candidate)? {
            continue;
        }
        let mtime = match host_telemetry::file_mtime_epoch_s(&candidate)? {
            Some(epoch_s) => epoch_s,
            None => continue,
        };

        match &newest {
            Some((best, _)) if *best >= mtime => {}
            _ => newest = Some((mtime, candidate)),
        }
    }

    Ok(newest.map(|(_, path)| path))
}

fn collect_sqlite_candidates(
    dir: &Path,
    depth: usize,
    candidates: &mut Vec<PathBuf>,
) -> Result<()> {
    if depth == 0 {
        return Ok(());
    }

    for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
        let entry = entry.with_context(|| format!("failed to read entry in {}", dir.display()))?;
        let path = entry.path();
        if path.is_dir() {
            collect_sqlite_candidates(&path, depth - 1, candidates)?;
            continue;
        }

        let matches = path
            .extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| matches!(ext, "db" | "sqlite" | "sqlite3"))
            .unwrap_or(false);
        if matches {
            candidates.push(path);
        }
    }

    Ok(())
}

fn looks_like_session_db(path: &Path) -> Result<bool> {
    let output = sqlite_query(
        path,
        "SELECT name FROM sqlite_master WHERE type='table' AND name='sessions' LIMIT 1;",
    )?;
    Ok(output.trim() == "sessions")
}

fn latest_session_metrics(path: &Path) -> Result<Option<(u64, u64, Option<String>)>> {
    let output = sqlite_query(
        path,
        "SELECT COALESCE(prompt_tokens, 0), COALESCE(completion_tokens, 0), COALESCE(summary_message_id, '') \
         FROM sessions ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 1;",
    )?;
    let mut parts = output.trim().splitn(3, '|');
    let prompt_tokens = match parts.next().and_then(|value| value.parse::<u64>().ok()) {
        Some(tokens) => tokens,
        None => return Ok(None),
    };
    let completion_tokens = match parts.next().and_then(|value| value.parse::<u64>().ok()) {
        Some(tokens) => tokens,
        None => return Ok(None),
    };
    let summary_message_id = parts.next().map(|value| value.to_owned());

    Ok(Some((prompt_tokens, completion_tokens, summary_message_id)))
}

fn sqlite_query(path: &Path, sql: &str) -> Result<String> {
    let output = Command::new("sqlite3")
        .arg("-batch")
        .arg("-noheader")
        .arg("-separator")
        .arg("|")
        .arg(path)
        .arg(sql)
        .output()
        .with_context(|| format!("failed to run sqlite3 against {}", path.display()))?;
    if !output.status.success() {
        return Ok(String::new());
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}

fn sqlite3_available() -> bool {
    Command::new("sqlite3")
        .arg("-version")
        .output()
        .map(|output| output.status.success())
        .unwrap_or(false)
}

fn opencode_context_window(repo_root: &Path) -> Result<Option<u64>> {
    if let Some(value) = host_telemetry::env_u64(OPENCODE_CONTEXT_WINDOW_VARS) {
        return Ok(Some(value));
    }

    let home = host_telemetry::home_dir()?;
    let candidates = [
        repo_root.join("opencode.json"),
        repo_root.join("opencode.jsonc"),
        home.join(".config/opencode/opencode.json"),
        home.join(".config/opencode/opencode.jsonc"),
    ];

    for candidate in candidates {
        if !candidate.is_file() {
            continue;
        }

        let contents = fs::read_to_string(&candidate)
            .with_context(|| format!("failed to read {}", candidate.display()))?;
        let value: Value =
            match jsonc_parser::parse_to_serde_value(&contents, &ParseOptions::default()) {
                Ok(Some(value)) => value,
                _ => continue,
            };

        if let Some(context) = find_limit_context(&value) {
            return Ok(Some(context));
        }
    }

    Ok(None)
}

fn opencode_model_name(repo_root: &Path) -> Result<Option<String>> {
    let home = host_telemetry::home_dir()?;
    let candidates = [
        repo_root.join("opencode.json"),
        repo_root.join("opencode.jsonc"),
        home.join(".config/opencode/opencode.json"),
        home.join(".config/opencode/opencode.jsonc"),
    ];

    for candidate in candidates {
        if !candidate.is_file() {
            continue;
        }

        let contents = fs::read_to_string(&candidate)
            .with_context(|| format!("failed to read {}", candidate.display()))?;
        let value: Value =
            match jsonc_parser::parse_to_serde_value(&contents, &ParseOptions::default()) {
                Ok(Some(value)) => value,
                _ => continue,
            };

        if let Some(model_name) = find_model_name(&value) {
            return Ok(Some(model_name));
        }
    }

    Ok(None)
}

/// Two-stage recursive search: find a `"limit"` object anywhere in the tree,
/// then look for a `"context"` numeric value inside that subtree.
fn find_limit_context(value: &Value) -> Option<u64> {
    match value {
        Value::Object(map) => {
            if let Some(limit) = map.get("limit") {
                if let Some(context) = number_key(limit, &["context"]) {
                    return Some(context);
                }
            }
            for child in map.values() {
                if let Some(context) = find_limit_context(child) {
                    return Some(context);
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(find_limit_context),
        _ => None,
    }
}

fn find_model_name(value: &Value) -> Option<String> {
    match value {
        Value::Object(map) => {
            if let Some(Value::Object(models)) = map.get("models") {
                if let Some(model_name) = models.keys().next() {
                    return Some(model_name.clone());
                }
            }
            for child in map.values() {
                if let Some(model_name) = find_model_name(child) {
                    return Some(model_name);
                }
            }
            None
        }
        Value::Array(items) => items.iter().find_map(find_model_name),
        _ => None,
    }
}