use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
pub cpu: CpuConfig,
pub pulse: PulseConfig,
pub chain: ChainConfig,
pub display: DisplayConfig,
pub color: ColorConfig,
pub refresh: RefreshConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CpuConfig {
pub emoji_thresholds: [f64; 4],
pub load_glyphs: Vec<String>,
pub sample_window_ms: u64,
pub precision_mode: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PulseConfig {
pub pulse_on_threshold: f64,
pub pulse_off_threshold: f64,
pub pulse_period_seconds: u64,
pub pulse_style: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ChainConfig {
pub chain_command: Option<String>,
pub order: String,
pub chain_cache_ttl_seconds: u64,
pub chain_timeout_ms: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DisplayConfig {
pub max_width: usize,
pub show_model: bool,
pub show_cost: bool,
pub show_context: bool,
pub show_git: bool,
pub show_battery: bool,
pub show_disk: bool,
pub show_network: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ColorConfig {
pub mode: String,
pub pulse_palette: Vec<String>,
pub band_tints: Vec<String>,
pub label_color: String,
pub separator: String,
pub separator_color: String,
pub hud_seam: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct RefreshConfig {
pub interval_seconds: u64,
}
impl Default for CpuConfig {
fn default() -> Self {
Self {
emoji_thresholds: [25.0, 50.0, 75.0, 90.0],
load_glyphs: vec![
"○".to_string(),
"▁".to_string(),
"▄".to_string(),
"▆".to_string(),
"◆".to_string(),
],
sample_window_ms: 25,
precision_mode: false,
}
}
}
impl Default for PulseConfig {
fn default() -> Self {
Self {
pulse_on_threshold: 90.0,
pulse_off_threshold: 80.0,
pulse_period_seconds: 30,
pulse_style: "calm".to_string(),
}
}
}
impl Default for ChainConfig {
fn default() -> Self {
Self {
chain_command: None,
order: "self_first".to_string(),
chain_cache_ttl_seconds: 10,
chain_timeout_ms: 500,
}
}
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
max_width: 80,
show_model: true,
show_cost: true,
show_context: true,
show_git: true,
show_battery: true,
show_disk: true,
show_network: true,
}
}
}
impl Default for ColorConfig {
fn default() -> Self {
Self {
mode: "auto".to_string(),
pulse_palette: vec!["#b87848".to_string(), "#7a5030".to_string()],
band_tints: vec![
"#5a6878".to_string(),
"#6d8296".to_string(),
"#86a0b4".to_string(),
"#9fbfce".to_string(),
"#b87848".to_string(),
],
label_color: "#6b7280".to_string(),
separator: " · ".to_string(),
separator_color: "#3b4048".to_string(),
hud_seam: "│".to_string(),
}
}
}
impl Default for RefreshConfig {
fn default() -> Self {
Self {
interval_seconds: 5,
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
cpu: CpuConfig::default(),
pulse: PulseConfig::default(),
chain: ChainConfig::default(),
display: DisplayConfig::default(),
color: ColorConfig::default(),
refresh: RefreshConfig::default(),
}
}
}
pub fn load_config() -> Config {
let path = match config_path() {
Some(path) => path,
None => return Config::default(),
};
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(_) => return Config::default(),
};
parse_config_str(&contents)
}
fn config_path() -> Option<std::path::PathBuf> {
if let Ok(override_path) = std::env::var("UNDERSTATUS_CONFIG") {
return Some(std::path::PathBuf::from(override_path));
}
let home = home_dir()?;
Some(home.join(".config").join("understatus").join("config.toml"))
}
pub fn parse_config_str(contents: &str) -> Config {
match toml::from_str::<Config>(contents) {
Ok(mut config) => {
expand_chain_command(&mut config);
config
}
Err(error) => {
eprintln!("understatus: config.toml 파싱 실패({error}). 기본값으로 진행합니다.");
Config::default()
}
}
}
fn expand_chain_command(config: &mut Config) {
let home = match home_dir() {
Some(home) => home,
None => return,
};
let home_str = home.to_string_lossy();
if let Some(command) = config.chain.chain_command.as_mut() {
let expanded = command
.replace("$HOME", &home_str)
.replace("${HOME}", &home_str);
let expanded = if let Some(rest) = expanded.strip_prefix("~/") {
format!("{home_str}/{rest}")
} else if expanded == "~" {
home_str.to_string()
} else {
expanded
};
*command = expanded;
}
}
fn home_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(std::path::PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_toml_is_default() {
let config = parse_config_str("");
assert_eq!(config.cpu.sample_window_ms, 25);
assert_eq!(config.pulse.pulse_on_threshold, 90.0);
assert_eq!(config.pulse.pulse_off_threshold, 80.0);
assert_eq!(config.chain.chain_cache_ttl_seconds, 10);
assert_eq!(config.chain.chain_timeout_ms, 500);
assert_eq!(config.display.max_width, 80);
assert!(config.display.show_disk);
assert!(config.display.show_network);
assert!(config.display.show_battery);
assert_eq!(config.refresh.interval_seconds, 5);
assert_eq!(config.chain.chain_command, None);
}
#[test]
fn default_impl_matches_spec() {
let config = Config::default();
assert_eq!(config.cpu.emoji_thresholds, [25.0, 50.0, 75.0, 90.0]);
assert_eq!(config.pulse.pulse_style, "calm");
assert_eq!(config.chain.order, "self_first");
assert_eq!(config.color.mode, "auto");
assert_eq!(
config.cpu.load_glyphs,
vec!["○", "▁", "▄", "▆", "◆"]
);
assert_eq!(
config.color.pulse_palette,
vec!["#b87848".to_string(), "#7a5030".to_string()]
);
assert_eq!(
config.color.band_tints,
vec!["#5a6878", "#6d8296", "#86a0b4", "#9fbfce", "#b87848"]
);
assert_eq!(config.color.label_color, "#6b7280");
assert_eq!(config.color.separator, " · ");
assert_eq!(config.color.separator_color, "#3b4048");
assert_eq!(config.color.hud_seam, "│");
}
#[test]
fn partial_toml_merges_with_defaults() {
let toml = r#"
[pulse]
pulse_on_threshold = 75
[display]
show_battery = false
"#;
let config = parse_config_str(toml);
assert_eq!(config.pulse.pulse_on_threshold, 75.0);
assert!(!config.display.show_battery);
assert_eq!(config.pulse.pulse_off_threshold, 80.0);
assert_eq!(config.cpu.sample_window_ms, 25);
assert!(config.display.show_model);
}
#[test]
fn broken_toml_falls_back_to_default() {
let config = parse_config_str("this is = = not valid toml ][");
assert_eq!(config.cpu.sample_window_ms, 25);
assert_eq!(config.pulse.pulse_on_threshold, 90.0);
}
#[test]
fn expands_home_var_in_chain_command() {
let home = std::env::var("HOME").expect("테스트 환경에 HOME 필요");
let toml = r#"
[chain]
chain_command = "node $HOME/.claude/hud/lterm-omc-hud.mjs"
"#;
let config = parse_config_str(toml);
let command = config.chain.chain_command.expect("chain_command 있어야 함");
assert_eq!(
command,
format!("node {home}/.claude/hud/lterm-omc-hud.mjs")
);
assert!(!command.contains("$HOME"));
}
#[test]
fn expands_leading_tilde_in_chain_command() {
let home = std::env::var("HOME").expect("테스트 환경에 HOME 필요");
let toml = r#"
[chain]
chain_command = "~/bin/myhud"
"#;
let config = parse_config_str(toml);
let command = config.chain.chain_command.expect("chain_command 있어야 함");
assert_eq!(command, format!("{home}/bin/myhud"));
}
}