ccd-cli 1.0.0-beta.4

Bootstrap and validate Continuous Context Development repositories
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};

const RECENT_HOST_ACTIVITY_SECS: u64 = 30 * 60;

#[derive(Debug, Clone)]
pub(crate) struct HostContextSnapshot {
    pub(crate) host: &'static str,
    pub(crate) observed_at_epoch_s: u64,
    pub(crate) model_name: Option<String>,
    pub(crate) context_used_pct: Option<u8>,
    pub(crate) total_tokens: Option<u64>,
    pub(crate) model_context_window: Option<u64>,
    pub(crate) compacted: Option<bool>,
    pub(crate) cost_usage: HostCostUsage,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct HostCostUsage {
    pub(crate) input_tokens: u64,
    pub(crate) output_tokens: u64,
    pub(crate) cache_creation_input_tokens: u64,
    pub(crate) cache_read_input_tokens: u64,
    pub(crate) blended_total_tokens: Option<u64>,
}

impl HostCostUsage {
    pub(crate) fn is_empty(&self) -> bool {
        self.input_tokens == 0
            && self.output_tokens == 0
            && self.cache_creation_input_tokens == 0
            && self.cache_read_input_tokens == 0
            && self.blended_total_tokens.unwrap_or(0) == 0
    }

    pub(crate) fn total_tokens(&self) -> u64 {
        self.blended_total_tokens.unwrap_or_else(|| {
            self.input_tokens
                .saturating_add(self.output_tokens)
                .saturating_add(self.cache_creation_input_tokens)
                .saturating_add(self.cache_read_input_tokens)
        })
    }
}

pub(crate) fn current(repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
    if let Some(host) = env_string(&["CCD_HOST_ADAPTER"]) {
        return snapshot_for_adapter(repo_root, &host);
    }

    if let Some(snapshot) = super::codex::current()? {
        return Ok(Some(snapshot));
    }

    let candidates = non_codex_snapshots(repo_root)?;

    Ok(candidates
        .into_iter()
        .max_by_key(|snapshot| snapshot.observed_at_epoch_s))
}

pub(crate) fn current_for_persistence(repo_root: &Path) -> Result<Option<HostContextSnapshot>> {
    if let Some(host) = env_string(&["CCD_HOST_ADAPTER"]) {
        return snapshot_for_adapter(repo_root, &host);
    }

    let mut candidates = all_snapshots(repo_root)?;
    if candidates.len() == 1 {
        return Ok(candidates.pop());
    }

    Ok(None)
}

fn snapshot_for_adapter(repo_root: &Path, host: &str) -> Result<Option<HostContextSnapshot>> {
    match crate::host_adapter::named(host) {
        Some(adapter) => adapter.snapshot(repo_root),
        None => Ok(None),
    }
}

fn all_snapshots(repo_root: &Path) -> Result<Vec<HostContextSnapshot>> {
    let mut candidates = Vec::new();
    for adapter in crate::host_adapter::all() {
        if let Some(snapshot) = adapter.snapshot(repo_root)? {
            candidates.push(snapshot);
        }
    }
    Ok(candidates)
}

fn non_codex_snapshots(repo_root: &Path) -> Result<Vec<HostContextSnapshot>> {
    let mut candidates = Vec::new();
    for adapter in crate::host_adapter::all() {
        if adapter.name() == "codex" {
            continue;
        }
        if let Some(snapshot) = adapter.snapshot(repo_root)? {
            candidates.push(snapshot);
        }
    }

    Ok(candidates)
}

pub(crate) fn env_string(names: &[&str]) -> Option<String> {
    names
        .iter()
        .find_map(|name| env::var(name).ok())
        .map(|value| value.trim().to_owned())
        .filter(|value| !value.is_empty())
}

pub(crate) fn env_u64(names: &[&str]) -> Option<u64> {
    env_string(names).and_then(|value| value.parse().ok())
}

/// General runtime-agnostic context-window fallback. Adapters should
/// consult their own adapter-specific env var and any native runtime
/// source first, then fall back to this. See ccd#529 for the rationale:
/// users with local or custom setups should not have to set a runtime-
/// prefixed env var — one `CCD_CONTEXT_WINDOW_TOKENS` is enough.
pub(crate) const GENERAL_CONTEXT_WINDOW_VARS: &[&str] = &["CCD_CONTEXT_WINDOW_TOKENS"];

pub(crate) fn general_context_window() -> Option<u64> {
    env_u64(GENERAL_CONTEXT_WINDOW_VARS)
}

pub(crate) fn file_mtime_epoch_s(path: &Path) -> Result<Option<u64>> {
    let metadata = match fs::metadata(path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(error).with_context(|| format!("failed to read {}", path.display()));
        }
    };

    let modified = metadata
        .modified()
        .with_context(|| format!("failed to read mtime for {}", path.display()))?;
    let epoch_s = modified
        .duration_since(UNIX_EPOCH)
        .with_context(|| format!("mtime for {} is before UNIX_EPOCH", path.display()))?
        .as_secs();

    Ok(Some(epoch_s))
}

pub(crate) fn is_recent(epoch_s: u64) -> Result<bool> {
    Ok(now_epoch_s()?.saturating_sub(epoch_s) <= RECENT_HOST_ACTIVITY_SECS)
}

pub(crate) fn now_epoch_s() -> Result<u64> {
    Ok(SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system clock is before UNIX_EPOCH")?
        .as_secs())
}

pub(crate) fn home_dir() -> Result<PathBuf> {
    match env::var_os("HOME") {
        Some(home) => Ok(PathBuf::from(home)),
        None => anyhow::bail!("HOME environment variable is not set"),
    }
}

pub(crate) fn compute_pct(total_tokens: u64, context_window: Option<u64>) -> Option<u8> {
    let context_window = context_window?;
    if context_window == 0 {
        return None;
    }

    Some(((total_tokens.saturating_mul(100)) / context_window).min(100) as u8)
}