Files
claude-statusline/src/config.rs
Taylor Eernisse 4c9139ec42 feat: implement remaining PRD features (10 beads)
Complete the PRD feature set with shell gating pipeline, cache
improvements, layout enhancements, and diagnostics:

- Shell: exec_gated with allowlist/denylist, circuit breaker, env merge
- Shell: parallel prefetch via std::thread::scope for cold renders
- Cache: TTL jitter (FNV-1a), config hash namespace, garbage collection
- Cache: diagnostic tracking (hit/miss, age) for dump-state
- Layout: gradual drop strategy (one-by-one vs tiered)
- Layout: render budget timer with graceful priority-based degradation
- Layout: breakpoint hysteresis to prevent preset toggling
- Width: detection source tracking for diagnostics
- CLI: --no-cache, --no-shell, --clear-cache, env var overrides
- Diagnostics: enhanced --dump-state with section timing and cache stats

Closes: bd-3oy, bd-62g, bd-khk, bd-3q1, bd-ywx, bd-3l2,
        bd-2vm, bd-1if, bd-2qr, bd-30o, bd-3ax, bd-3uw, bd-4b1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:59:15 -05:00

740 lines
18 KiB
Rust

use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
const DEFAULTS_JSON: &str = include_str!("../defaults.json");
// ── Top-level Config ────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct Config {
pub version: u32,
pub global: GlobalConfig,
pub colors: ThemeColors,
pub glyphs: GlyphConfig,
pub presets: HashMap<String, Vec<Vec<String>>>,
pub layout: LayoutValue,
pub sections: Sections,
pub custom: Vec<CustomCommand>,
}
impl Default for Config {
fn default() -> Self {
serde_json::from_str(DEFAULTS_JSON).expect("embedded defaults must parse")
}
}
// ── Layout: preset name or explicit array ───────────────────────────────
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LayoutValue {
Preset(String),
Custom(Vec<Vec<String>>),
}
impl Default for LayoutValue {
fn default() -> Self {
Self::Preset("standard".into())
}
}
// ── Global settings ─────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct GlobalConfig {
pub separator: String,
pub justify: JustifyMode,
pub vcs: String,
pub width: Option<u16>,
pub width_margin: u16,
pub cache_dir: String,
pub cache_gc_days: u16,
pub cache_gc_interval_hours: u16,
pub cache_ttl_jitter_pct: u8,
pub responsive: bool,
pub breakpoints: Breakpoints,
pub render_budget_ms: u64,
pub theme: String,
pub color: ColorMode,
pub warn_unknown_keys: bool,
pub shell_enabled: bool,
pub shell_allowlist: Vec<String>,
pub shell_denylist: Vec<String>,
pub shell_timeout_ms: u64,
pub shell_max_output_bytes: usize,
pub shell_failure_threshold: u8,
pub shell_cooldown_ms: u64,
pub shell_env: HashMap<String, String>,
pub cache_version: u32,
pub drop_strategy: String,
pub breakpoint_hysteresis: u16,
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
separator: " | ".into(),
justify: JustifyMode::Left,
vcs: "auto".into(),
width: None,
width_margin: 4,
cache_dir: "/tmp/claude-sl-{session_id}".into(),
cache_gc_days: 7,
cache_gc_interval_hours: 24,
cache_ttl_jitter_pct: 10,
responsive: true,
breakpoints: Breakpoints::default(),
render_budget_ms: 8,
theme: "auto".into(),
color: ColorMode::Auto,
warn_unknown_keys: true,
shell_enabled: true,
shell_allowlist: Vec::new(),
shell_denylist: Vec::new(),
shell_timeout_ms: 200,
shell_max_output_bytes: 8192,
shell_failure_threshold: 3,
shell_cooldown_ms: 30_000,
shell_env: HashMap::new(),
cache_version: 1,
drop_strategy: "tiered".into(),
breakpoint_hysteresis: 2,
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum JustifyMode {
#[default]
Left,
Spread,
SpaceBetween,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Breakpoints {
pub narrow: u16,
pub medium: u16,
pub hysteresis: u16,
}
impl Default for Breakpoints {
fn default() -> Self {
Self {
narrow: 60,
medium: 100,
hysteresis: 2,
}
}
}
// ── Color palettes ──────────────────────────────────────────────────────
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ThemeColors {
pub dark: HashMap<String, String>,
pub light: HashMap<String, String>,
}
// ── Glyph config ────────────────────────────────────────────────────────
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct GlyphConfig {
pub enabled: bool,
pub set: HashMap<String, String>,
pub fallback: HashMap<String, String>,
}
// ── Shared section base (flattened into each section) ───────────────────
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct SectionBase {
pub enabled: bool,
pub priority: u8,
pub flex: bool,
pub min_width: Option<u16>,
pub prefix: Option<String>,
pub suffix: Option<String>,
pub pad: Option<u16>,
pub align: Option<String>,
pub color: Option<String>,
}
impl Default for SectionBase {
fn default() -> Self {
Self {
enabled: true,
priority: 2,
flex: false,
min_width: None,
prefix: None,
suffix: None,
pad: None,
align: None,
color: None,
}
}
}
// ── Per-section typed configs ───────────────────────────────────────────
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Sections {
pub model: SectionBase,
pub provider: SectionBase,
pub project: ProjectSection,
pub vcs: VcsSection,
pub beads: BeadsSection,
pub context_bar: ContextBarSection,
pub context_usage: ContextUsageSection,
pub context_remaining: ContextRemainingSection,
pub tokens_raw: TokensRawSection,
pub cache_efficiency: SectionBase,
pub cost: CostSection,
pub cost_velocity: SectionBase,
pub token_velocity: SectionBase,
pub cost_trend: TrendSection,
pub context_trend: ContextTrendSection,
pub lines_changed: SectionBase,
pub duration: SectionBase,
pub tools: ToolsSection,
pub turns: CachedSection,
pub load: CachedSection,
pub version: SectionBase,
pub time: TimeSection,
pub output_style: SectionBase,
pub hostname: SectionBase,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ProjectSection {
#[serde(flatten)]
pub base: SectionBase,
pub truncate: TruncateConfig,
}
impl Default for ProjectSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 1,
..Default::default()
},
truncate: TruncateConfig {
enabled: true,
max: 30,
style: "middle".into(),
},
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TruncateConfig {
pub enabled: bool,
pub max: usize,
pub style: String,
}
impl Default for TruncateConfig {
fn default() -> Self {
Self {
enabled: false,
max: 0,
style: "right".into(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct VcsSection {
#[serde(flatten)]
pub base: SectionBase,
pub prefer: String,
pub show_ahead_behind: bool,
pub show_dirty: bool,
pub untracked: String,
pub submodules: bool,
pub fast_mode: bool,
pub truncate: TruncateConfig,
pub ttl: VcsTtl,
}
impl Default for VcsSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 1,
min_width: Some(8),
..Default::default()
},
prefer: "auto".into(),
show_ahead_behind: true,
show_dirty: true,
untracked: "normal".into(),
submodules: false,
fast_mode: false,
truncate: TruncateConfig {
enabled: true,
max: 25,
style: "right".into(),
},
ttl: VcsTtl::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct VcsTtl {
pub branch: u64,
pub dirty: u64,
pub ahead_behind: u64,
}
impl Default for VcsTtl {
fn default() -> Self {
Self {
branch: 3,
dirty: 5,
ahead_behind: 30,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct BeadsSection {
#[serde(flatten)]
pub base: SectionBase,
pub show_wip: bool,
pub show_wip_count: bool,
pub show_ready_count: bool,
pub show_open_count: bool,
pub show_closed_count: bool,
pub ttl: u64,
}
impl Default for BeadsSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 3,
..Default::default()
},
show_wip: true,
show_wip_count: true,
show_ready_count: true,
show_open_count: true,
show_closed_count: true,
ttl: 30,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ContextBarSection {
#[serde(flatten)]
pub base: SectionBase,
pub bar_width: u16,
pub thresholds: Thresholds,
}
impl Default for ContextBarSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 1,
flex: true,
min_width: Some(15),
..Default::default()
},
bar_width: 10,
thresholds: Thresholds {
warn: 50.0,
danger: 70.0,
critical: 85.0,
},
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Thresholds {
pub warn: f64,
pub danger: f64,
pub critical: f64,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
warn: 50.0,
danger: 70.0,
critical: 85.0,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ContextUsageSection {
#[serde(flatten)]
pub base: SectionBase,
pub capacity: u64,
pub thresholds: Thresholds,
}
impl Default for ContextUsageSection {
fn default() -> Self {
Self {
base: SectionBase {
enabled: false,
priority: 2,
..Default::default()
},
capacity: 200_000,
thresholds: Thresholds::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ContextRemainingSection {
#[serde(flatten)]
pub base: SectionBase,
pub format: String,
pub thresholds: Thresholds,
}
impl Default for ContextRemainingSection {
fn default() -> Self {
Self {
base: SectionBase {
enabled: false,
priority: 2,
..Default::default()
},
format: "{remaining} left".into(),
thresholds: Thresholds::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct TokensRawSection {
#[serde(flatten)]
pub base: SectionBase,
pub format: String,
}
impl Default for TokensRawSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 3,
..Default::default()
},
format: "{input} in/{output} out".into(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct CostSection {
#[serde(flatten)]
pub base: SectionBase,
pub thresholds: Thresholds,
}
impl Default for CostSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 1,
..Default::default()
},
thresholds: Thresholds {
warn: 5.0,
danger: 8.0,
critical: 10.0,
},
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct TrendSection {
#[serde(flatten)]
pub base: SectionBase,
pub width: u8,
}
impl Default for TrendSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 3,
..Default::default()
},
width: 8,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ContextTrendSection {
#[serde(flatten)]
pub base: SectionBase,
pub width: u8,
pub thresholds: Thresholds,
}
impl Default for ContextTrendSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 3,
..Default::default()
},
width: 8,
thresholds: Thresholds::default(),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct ToolsSection {
#[serde(flatten)]
pub base: SectionBase,
pub show_last_name: bool,
pub ttl: u64,
}
impl Default for ToolsSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 2,
min_width: Some(6),
..Default::default()
},
show_last_name: true,
ttl: 2,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct CachedSection {
#[serde(flatten)]
pub base: SectionBase,
pub ttl: u64,
}
impl Default for CachedSection {
fn default() -> Self {
Self {
base: SectionBase {
priority: 3,
..Default::default()
},
ttl: 10,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct TimeSection {
#[serde(flatten)]
pub base: SectionBase,
pub format: String,
}
impl Default for TimeSection {
fn default() -> Self {
Self {
base: SectionBase {
enabled: false,
priority: 3,
..Default::default()
},
format: "%H:%M".into(),
}
}
}
// ── Custom command sections ─────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
pub struct CustomCommand {
pub id: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub exec: Option<Vec<String>>,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default = "default_custom_ttl")]
pub ttl: u64,
#[serde(default = "default_priority")]
pub priority: u8,
#[serde(default)]
pub flex: bool,
#[serde(default)]
pub min_width: Option<u16>,
#[serde(default)]
pub color: Option<CustomColor>,
#[serde(default)]
pub default_color: Option<String>,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub suffix: Option<String>,
#[serde(default)]
pub pad: Option<u16>,
#[serde(default)]
pub align: Option<String>,
}
fn default_custom_ttl() -> u64 {
30
}
fn default_priority() -> u8 {
2
}
#[derive(Debug, Clone, Deserialize)]
pub struct CustomColor {
#[serde(rename = "match", default)]
pub match_map: HashMap<String, String>,
}
// ── Deep merge ──────────────────────────────────────────────────────────
/// Recursive JSON merge: user values win, arrays replaced entirely.
pub fn deep_merge(base: &mut Value, patch: &Value) {
match (base, patch) {
(Value::Object(base_map), Value::Object(patch_map)) => {
for (k, v) in patch_map {
let entry = base_map.entry(k.clone()).or_insert(Value::Null);
deep_merge(entry, v);
}
}
(base, patch) => {
*base = patch.clone();
}
}
}
// ── Config loading ──────────────────────────────────────────────────────
/// Load config: embedded defaults deep-merged with user overrides.
/// Returns (Config, warnings, config_hash) where config_hash is 8-char hex MD5
/// of the merged JSON (for cache namespace invalidation on config change).
pub fn load_config(
explicit_path: Option<&str>,
) -> Result<(Config, Vec<String>, String), crate::Error> {
let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
let user_path = explicit_path
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var("CLAUDE_STATUSLINE_CONFIG")
.ok()
.map(Into::into)
})
.or_else(xdg_config_path)
.or_else(dot_config_path)
.unwrap_or_else(|| {
let mut p = dirs_home().unwrap_or_default();
p.push(".claude/statusline.json");
p
});
if user_path.exists() {
let user_json: Value = serde_json::from_str(&std::fs::read_to_string(&user_path)?)?;
deep_merge(&mut base, &user_json);
} else if explicit_path.is_some() {
return Err(crate::Error::ConfigNotFound(
user_path.display().to_string(),
));
}
// Compute config hash from merged JSON before deserialize consumes it
let config_hash = compute_config_hash(&base);
let mut warnings = Vec::new();
let config: Config = serde_ignored::deserialize(base, |path| {
warnings.push(format!("unknown config key: {path}"));
})?;
Ok((config, warnings, config_hash))
}
/// MD5 of the merged JSON value, truncated to 8 hex chars.
/// Deterministic: serde_json produces stable output for the same Value.
fn compute_config_hash(merged: &Value) -> String {
use md5::{Digest, Md5};
let json_bytes = serde_json::to_string(merged).unwrap_or_default();
let hash = Md5::digest(json_bytes.as_bytes());
format!("{:x}", hash)[..8].to_string()
}
fn xdg_config_path() -> Option<std::path::PathBuf> {
let val = std::env::var("XDG_CONFIG_HOME").ok()?;
let mut p = std::path::PathBuf::from(val);
p.push("claude/statusline.json");
if p.exists() {
Some(p)
} else {
None
}
}
fn dot_config_path() -> Option<std::path::PathBuf> {
let mut p = dirs_home()?;
p.push(".config/claude/statusline.json");
if p.exists() {
Some(p)
} else {
None
}
}
fn dirs_home() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(Into::into)
}