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>>, pub layout: LayoutValue, pub sections: Sections, pub custom: Vec, } 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>), } 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, 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, pub shell_denylist: Vec, 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, 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, pub light: HashMap, } // ── Glyph config ──────────────────────────────────────────────────────── #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct GlyphConfig { pub enabled: bool, pub set: HashMap, pub fallback: HashMap, } // ── 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, pub prefix: Option, pub suffix: Option, pub pad: Option, pub align: Option, pub color: Option, } 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, #[serde(default)] pub exec: Option>, #[serde(default)] pub format: Option, #[serde(default)] pub label: Option, #[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, #[serde(default)] pub color: Option, #[serde(default)] pub default_color: Option, #[serde(default)] pub prefix: Option, #[serde(default)] pub suffix: Option, #[serde(default)] pub pad: Option, #[serde(default)] pub align: Option, } 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, } // ── 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), 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 { 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 { let mut p = dirs_home()?; p.push(".config/claude/statusline.json"); if p.exists() { Some(p) } else { None } } fn dirs_home() -> Option { std::env::var("HOME").ok().map(Into::into) }