ccd-cli 1.0.0-alpha.2

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 host {
        "codex" => super::codex::current(),
        "claude" => super::claude::current(repo_root),
        "gemini" => super::gemini::current(repo_root),
        "opencode" => super::opencode::current(repo_root),
        _ => Ok(None),
    }
}

fn all_snapshots(repo_root: &Path) -> Result<Vec<HostContextSnapshot>> {
    let mut candidates = Vec::new();
    if let Some(snapshot) = super::codex::current()? {
        candidates.push(snapshot);
    }
    candidates.extend(non_codex_snapshots(repo_root)?);
    Ok(candidates)
}

fn non_codex_snapshots(repo_root: &Path) -> Result<Vec<HostContextSnapshot>> {
    let mut candidates = Vec::new();

    if let Some(snapshot) = super::claude::current(repo_root)? {
        candidates.push(snapshot);
    }
    if let Some(snapshot) = super::gemini::current(repo_root)? {
        candidates.push(snapshot);
    }
    if let Some(snapshot) = super::opencode::current(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())
}

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)
}