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