Files
claude-statusline/src/cache.rs
Taylor Eernisse e0c4a0fa9a feat: add colorgrad, transcript parser, terminal palette detection, and expanded color/input systems
Infrastructure layer for the TUI visual overhaul. Introduces foundational
modules and capabilities that the section-level features build on:

colorgrad (0.7) dependency:
  OKLab gradient interpolation for per-character color transitions in
  sparklines and context bars. Adds ~100K to binary (929K -> 1.0M).

color.rs expansion:
  - parse_hex(): #RRGGBB and #RGB -> (u8, u8, u8) conversion
  - fg_rgb()/bg_rgb(): 24-bit true-color ANSI escape generation
  - gradient_fg(): two-point interpolation via colorgrad
  - make_gradient()/sample_fg(): multi-stop gradient construction and sampling
  - resolve_color() now supports: hex (#FF6B35), bg:color, bg:#hex,
    italic, underline, strikethrough, and palette refs (p:success)
  - Named background constants (BG_RED through BG_WHITE)

transcript.rs (new module):
  Parses Claude Code transcript JSONL files to derive tool use counts,
  turn counts, and per-tool breakdowns. Claude Code doesn't include
  total_tool_uses or total_turns in its JSON — we compute them by scanning
  the transcript. Includes compact cache serialization format and
  skip_lines support for /clear offset handling.

terminal.rs (new module):
  Auto-detects the terminal's ANSI color palette for theme-aware tool
  coloring. Priority chain: WezTerm config > Kitty config > Alacritty
  config > OSC 4 escape sequence query. Parses Lua (WezTerm), key-value
  (Kitty), and TOML/YAML (Alacritty) config formats. OSC 4 queries
  use raw /dev/tty I/O with termios to avoid pipe interference. Includes
  cache serialization helpers for 1-hour TTL caching.

input.rs updates:
  - All structs now derive Serialize (for --dump-state diagnostics)
  - New fields: transcript_path, session_id, cwd, vim.mode, agent.name,
    exceeds_200k_tokens, cost.total_api_duration_ms
  - CurrentUsage: added input_tokens and output_tokens fields
  - #[serde(flatten)] extras on InputData and CostInfo for forward compat

cache.rs:
  Added flush_prefix() for /clear detection — removes all cache entries
  matching a key prefix (e.g., "trend_" to reset all sparkline history).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:41:50 -05:00

374 lines
11 KiB
Rust

use std::cell::RefCell;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// Diagnostic entry for a single cache lookup.
#[derive(Debug, Clone)]
pub struct CacheDiag {
pub key: String,
pub hit: bool,
pub age_ms: Option<u64>,
}
pub struct Cache {
dir: Option<PathBuf>,
jitter_pct: u8,
diagnostics: RefCell<Vec<CacheDiag>>,
}
impl Cache {
/// Create a disabled cache where all operations are no-ops.
/// Used for --no-cache mode.
pub fn disabled() -> Self {
Self {
dir: None,
jitter_pct: 0,
diagnostics: RefCell::new(Vec::new()),
}
}
/// Create cache with secure directory. Returns disabled cache on failure.
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
pub fn new(
template: &str,
session_id: &str,
cache_version: u32,
config_hash: &str,
jitter_pct: u8,
) -> Self {
let dir_str = template
.replace("{session_id}", session_id)
.replace("{cache_version}", &cache_version.to_string())
.replace("{config_hash}", config_hash);
let dir = PathBuf::from(&dir_str);
if !dir.exists() {
if fs::create_dir_all(&dir).is_err() {
return Self {
dir: None,
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
};
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
}
}
// Security: verify ownership, not a symlink, not world-writable
if !verify_cache_dir(&dir) {
return Self {
dir: None,
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
};
}
Self {
dir: Some(dir),
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
}
}
pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
/// Get cached value if fresher than TTL (with per-key jitter applied).
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
let path = match self.key_path(key) {
Some(p) => p,
None => {
self.record_diag(key, false, None);
return None;
}
};
let meta = match fs::metadata(&path).ok() {
Some(m) => m,
None => {
self.record_diag(key, false, None);
return None;
}
};
let modified = match meta.modified().ok() {
Some(m) => m,
None => {
self.record_diag(key, false, None);
return None;
}
};
let age = match SystemTime::now().duration_since(modified).ok() {
Some(a) => a,
None => {
self.record_diag(key, false, None);
return None;
}
};
let age_ms = age.as_millis() as u64;
let effective_ttl = self.jittered_ttl(key, ttl);
if age < effective_ttl {
let value = fs::read_to_string(&path).ok();
self.record_diag(key, value.is_some(), Some(age_ms));
value
} else {
self.record_diag(key, false, Some(age_ms));
None
}
}
fn record_diag(&self, key: &str, hit: bool, age_ms: Option<u64>) {
if let Ok(mut diags) = self.diagnostics.try_borrow_mut() {
diags.push(CacheDiag {
key: key.to_string(),
hit,
age_ms,
});
}
}
/// Return collected cache diagnostics (for --dump-state).
pub fn diagnostics(&self) -> Vec<CacheDiag> {
self.diagnostics.borrow().clone()
}
/// Apply deterministic per-key jitter to TTL.
/// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time).
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
if self.jitter_pct == 0 {
return base_ttl;
}
// FNV-1a hash of key for deterministic per-key jitter
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in key.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
// Map hash to range [-jitter_pct, +jitter_pct]
let jitter_range = f64::from(self.jitter_pct) / 100.0;
let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
let multiplier = 1.0 + (normalized * jitter_range);
let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
// Clamp: minimum 100ms to avoid zero TTL
Duration::from_millis(jittered_ms.max(100))
}
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
pub fn get_stale(&self, key: &str) -> Option<String> {
let path = self.key_path(key)?;
fs::read_to_string(&path).ok()
}
/// Atomic write: write to .tmp then rename (prevents partial reads).
/// Uses flock with LOCK_NB — skips cache on contention rather than blocking.
pub fn set(&self, key: &str, value: &str) -> Option<()> {
let path = self.key_path(key)?;
let tmp = path.with_extension("tmp");
// Try non-blocking flock
let lock_path = path.with_extension("lock");
let lock_file = fs::File::create(&lock_path).ok()?;
if !try_flock(&lock_file) {
return None; // contention — skip cache write
}
let mut f = fs::File::create(&tmp).ok()?;
f.write_all(value.as_bytes()).ok()?;
fs::rename(&tmp, &path).ok()?;
unlock(&lock_file);
Some(())
}
/// Remove cache entries matching a prefix (e.g., "trend_" to flush all trend data).
pub fn flush_prefix(&self, prefix: &str) {
let dir = match &self.dir {
Some(d) => d,
None => return,
};
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(prefix) {
let _ = fs::remove_file(entry.path());
}
}
}
}
fn key_path(&self, key: &str) -> Option<PathBuf> {
let dir = self.dir.as_ref()?;
let safe_key: String = key
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
Some(dir.join(safe_key))
}
}
fn verify_cache_dir(dir: &Path) -> bool {
let meta = match fs::symlink_metadata(dir) {
Ok(m) => m,
Err(_) => return false,
};
if !meta.is_dir() || meta.file_type().is_symlink() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Must be owned by current user
if meta.uid() != unsafe { libc::getuid() } {
return false;
}
// Must not be world-writable
if meta.mode() & 0o002 != 0 {
return false;
}
}
true
}
fn try_flock(file: &fs::File) -> bool {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
ret == 0
}
#[cfg(not(unix))]
{
let _ = file;
true
}
}
fn unlock(file: &fs::File) {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
unsafe {
libc::flock(file.as_raw_fd(), libc::LOCK_UN);
}
}
#[cfg(not(unix))]
{
let _ = file;
}
}
/// Session ID: first 12 chars of MD5 hex of project_dir.
/// Same algorithm as bash for cache compatibility during migration.
pub fn session_id(project_dir: &str) -> String {
use md5::{Digest, Md5};
let hash = Md5::digest(project_dir.as_bytes());
format!("{:x}", hash)[..12].to_string()
}
/// Garbage-collect old cache directories.
/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days`
/// that match /tmp/claude-sl-* and are owned by the current user.
/// Never blocks: uses non-blocking flock on a sentinel file.
pub fn gc(gc_days: u16, gc_interval_hours: u16) {
let lock_path = Path::new("/tmp/claude-sl-gc.lock");
// Check interval: if lock file exists and is younger than gc_interval, skip
if let Ok(meta) = fs::metadata(lock_path) {
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) {
return;
}
}
}
}
// Try non-blocking lock
let lock_file = match fs::File::create(lock_path) {
Ok(f) => f,
Err(_) => return,
};
if !try_flock(&lock_file) {
return; // another process is GC-ing
}
// Touch the lock file (create already set mtime to now)
let max_age = Duration::from_secs(u64::from(gc_days) * 86400);
let entries = match fs::read_dir("/tmp") {
Ok(e) => e,
Err(_) => {
unlock(&lock_file);
return;
}
};
let uid = current_uid();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("claude-sl-") {
continue;
}
let path = entry.path();
// Safety: skip symlinks
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_dir() || meta.file_type().is_symlink() {
continue;
}
// Only delete dirs owned by current user
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.uid() != uid {
continue;
}
}
// Check age
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age > max_age {
let _ = fs::remove_dir_all(&path);
}
}
}
}
unlock(&lock_file);
}
fn current_uid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::getuid() }
}
#[cfg(not(unix))]
{
0
}
}