use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Map, Value};
use crate::config::{self, Config};
use crate::theme;
use crate::themes;
const STATUS_LINE_KEY: &str = "statusLine";
const COMMAND_KEY: &str = "command";
const REFRESH_INTERVAL_KEY: &str = "refreshInterval";
const TYPE_KEY: &str = "type";
const PADDING_KEY: &str = "padding";
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstallRecord {
original_command: Option<String>,
had_status_line: bool,
original_refresh_interval: Option<Value>,
original_padding: Option<Value>,
}
fn apply_install(
settings: &mut Value,
understatus_path: &str,
refresh_interval: u64,
) -> InstallRecord {
let root = ensure_object(settings);
let existing_status_line = root.get(STATUS_LINE_KEY).cloned();
let had_status_line = matches!(existing_status_line, Some(Value::Object(_)));
let already_installed = existing_status_line
.as_ref()
.and_then(|status_line| status_line.get(COMMAND_KEY))
.and_then(Value::as_str)
== Some(understatus_path);
let record = if already_installed {
InstallRecord {
original_command: None,
had_status_line: true,
original_refresh_interval: None,
original_padding: None,
}
} else {
let original_command = existing_status_line
.as_ref()
.and_then(|status_line| status_line.get(COMMAND_KEY))
.and_then(Value::as_str)
.map(str::to_string);
let original_refresh_interval = existing_status_line
.as_ref()
.and_then(|status_line| status_line.get(REFRESH_INTERVAL_KEY))
.cloned();
let original_padding = existing_status_line
.as_ref()
.and_then(|status_line| status_line.get(PADDING_KEY))
.cloned();
InstallRecord {
original_command,
had_status_line,
original_refresh_interval,
original_padding,
}
};
let mut status_line = match existing_status_line {
Some(Value::Object(map)) => map,
_ => Map::new(),
};
status_line.insert(TYPE_KEY.to_string(), json!("command"));
status_line.insert(COMMAND_KEY.to_string(), json!(understatus_path));
status_line.insert(PADDING_KEY.to_string(), json!(0));
status_line.insert(
REFRESH_INTERVAL_KEY.to_string(),
json!(refresh_interval as i64),
);
root.insert(STATUS_LINE_KEY.to_string(), Value::Object(status_line));
record
}
fn apply_uninstall(settings: &mut Value, record: &InstallRecord) {
let root = ensure_object(settings);
if !record.had_status_line {
root.remove(STATUS_LINE_KEY);
return;
}
let Some(Value::Object(status_line)) = root.get_mut(STATUS_LINE_KEY) else {
return;
};
match &record.original_command {
Some(command) => {
status_line.insert(COMMAND_KEY.to_string(), json!(command));
}
None => {
status_line.remove(COMMAND_KEY);
}
}
match &record.original_refresh_interval {
Some(value) => {
status_line.insert(REFRESH_INTERVAL_KEY.to_string(), value.clone());
}
None => {
status_line.remove(REFRESH_INTERVAL_KEY);
}
}
match &record.original_padding {
Some(value) => {
status_line.insert(PADDING_KEY.to_string(), value.clone());
}
None => {
status_line.remove(PADDING_KEY);
}
}
}
fn ensure_object(settings: &mut Value) -> &mut Map<String, Value> {
if !settings.is_object() {
*settings = Value::Object(Map::new());
}
settings
.as_object_mut()
.expect("ensure_object: 위에서 객체로 보장했으므로 항상 Some")
}
pub fn install(interval: u64, theme: &str) -> Result<()> {
validate_theme(theme)?;
let settings_path = settings_json_path()?;
let understatus_path = understatus_binary_path()?;
let raw = std::fs::read_to_string(&settings_path)
.with_context(|| format!("settings.json 읽기 실패: {}", settings_path.display()))?;
let mut settings: Value = serde_json::from_str(&raw)
.with_context(|| format!("settings.json JSON 파싱 실패: {}", settings_path.display()))?;
let backup_path = backup_json_path(&settings_path);
if !backup_path.exists() {
std::fs::write(&backup_path, &raw)
.with_context(|| format!("백업 파일 쓰기 실패: {}", backup_path.display()))?;
}
let original_command = settings
.get(STATUS_LINE_KEY)
.and_then(|status_line| status_line.get(COMMAND_KEY))
.and_then(Value::as_str)
.filter(|command| *command != understatus_path)
.map(str::to_string);
let cfg_for_warn = read_existing_config_str()
.as_deref()
.map(config::parse_config_str)
.unwrap_or_default();
edit_config_doc(|table| {
if let Some(command) = &original_command {
set_chain_command(table, command);
}
table.insert("theme".to_string(), toml::Value::String(theme.to_string()));
set_refresh_interval(table, interval);
Ok(())
})?;
let _record = apply_install(&mut settings, &understatus_path, interval);
write_pretty_json(&settings_path, &settings)?;
warn_if_pulse_period_too_short(&cfg_for_warn, interval);
Ok(())
}
pub fn set_theme(name: &str) -> Result<()> {
validate_theme(name)?;
edit_config_doc(|table| {
table.insert("theme".to_string(), toml::Value::String(name.to_string()));
Ok(())
})
}
fn validate_theme(name: &str) -> Result<()> {
if themes::is_known(name) {
return Ok(());
}
Err(anyhow!(
"알 수 없는 테마 '{name}'. 사용 가능: {}",
known_theme_names()
))
}
fn known_theme_names() -> String {
themes::catalog()
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn read_existing_config_str() -> Option<String> {
let path = config_toml_path()?;
std::fs::read_to_string(&path).ok()
}
pub(crate) fn existing_interval(existing: Option<&str>) -> Option<u64> {
let raw = existing?;
let value: toml::Value = toml::from_str(raw).ok()?;
value
.get("refresh")?
.get("interval_seconds")?
.as_integer()
.filter(|n| *n >= 1)
.map(|n| n as u64)
}
pub fn uninstall() -> Result<()> {
let settings_path = settings_json_path()?;
let backup_path = backup_json_path(&settings_path);
if backup_path.exists() {
let backup_raw = std::fs::read_to_string(&backup_path)
.with_context(|| format!("백업 읽기 실패: {}", backup_path.display()))?;
std::fs::write(&settings_path, &backup_raw).with_context(|| {
format!("settings.json 복원 쓰기 실패: {}", settings_path.display())
})?;
std::fs::remove_file(&backup_path)
.with_context(|| format!("백업 제거 실패: {}", backup_path.display()))?;
} else if settings_path.exists() {
let raw = std::fs::read_to_string(&settings_path)
.with_context(|| format!("settings.json 읽기 실패: {}", settings_path.display()))?;
let mut settings: Value = serde_json::from_str(&raw).with_context(|| {
format!("settings.json JSON 파싱 실패: {}", settings_path.display())
})?;
let record = InstallRecord {
original_command: read_chain_command_from_config(),
had_status_line: settings
.get(STATUS_LINE_KEY)
.map(Value::is_object)
.unwrap_or(false),
original_refresh_interval: None,
original_padding: None,
};
apply_uninstall(&mut settings, &record);
write_pretty_json(&settings_path, &settings)?;
}
if let Some(cache_dir) = cache_dir_path() {
if cache_dir.exists() {
let _ = std::fs::remove_dir_all(&cache_dir);
}
}
Ok(())
}
fn settings_json_path() -> Result<PathBuf> {
let home = home_dir().ok_or_else(|| anyhow!("HOME 환경변수를 찾을 수 없습니다"))?;
Ok(home.join(".claude").join("settings.json"))
}
fn backup_json_path(settings_path: &std::path::Path) -> PathBuf {
let mut backup = settings_path.as_os_str().to_owned();
backup.push(".understatus.bak");
PathBuf::from(backup)
}
fn config_dir_path() -> Option<PathBuf> {
home_dir().map(|home| home.join(".config").join("understatus"))
}
fn config_toml_path() -> Option<PathBuf> {
if let Ok(override_path) = std::env::var("UNDERSTATUS_CONFIG") {
return Some(PathBuf::from(override_path));
}
config_dir_path().map(|dir| dir.join("config.toml"))
}
fn cache_dir_path() -> Option<PathBuf> {
home_dir().map(|home| home.join("Library").join("Caches").join("understatus"))
}
fn understatus_binary_path() -> Result<String> {
let exe = std::env::current_exe().context("현재 실행 바이너리 경로 확인 실패")?;
let canonical = std::fs::canonicalize(&exe).unwrap_or(exe);
canonical
.to_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("바이너리 경로에 비-UTF8 문자가 포함되어 있습니다"))
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn write_pretty_json(path: &std::path::Path, settings: &Value) -> Result<()> {
let mut pretty = serde_json::to_string_pretty(settings).context("settings.json 직렬화 실패")?;
pretty.push('\n');
std::fs::write(path, pretty)
.with_context(|| format!("settings.json 쓰기 실패: {}", path.display()))?;
Ok(())
}
fn edit_config_doc_str<F>(existing: Option<&str>, edit: F) -> Result<String>
where
F: FnOnce(&mut toml::value::Table) -> Result<()>,
{
let mut doc: toml::Value = match existing {
Some(raw) => toml::from_str(raw).context("config.toml 파싱 실패")?,
None => toml::Value::Table(toml::map::Map::new()),
};
let table = doc
.as_table_mut()
.ok_or_else(|| anyhow!("config.toml 최상위가 테이블이 아닙니다"))?;
edit(table)?;
toml::to_string_pretty(&doc).context("config.toml 직렬화 실패")
}
fn edit_config_doc<F>(edit: F) -> Result<()>
where
F: FnOnce(&mut toml::value::Table) -> Result<()>,
{
let Some(config_path) = config_toml_path() else {
return Err(anyhow!("config.toml 경로를 확인할 수 없습니다"));
};
let existing = std::fs::read_to_string(&config_path).ok();
let serialized = edit_config_doc_str(existing.as_deref(), edit)?;
if let Some(dir) = config_path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("config 디렉터리 생성 실패: {}", dir.display()))?;
}
std::fs::write(&config_path, serialized)
.with_context(|| format!("config.toml 쓰기 실패: {}", config_path.display()))?;
Ok(())
}
fn set_chain_command(table: &mut toml::value::Table, command: &str) {
let chain = table
.entry("chain".to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
if !chain.is_table() {
*chain = toml::Value::Table(toml::map::Map::new());
}
let chain_table = chain
.as_table_mut()
.expect("set_chain_command: 위에서 테이블로 보장했으므로 항상 Some");
chain_table.insert(
"chain_command".to_string(),
toml::Value::String(command.to_string()),
);
}
fn set_refresh_interval(table: &mut toml::value::Table, interval: u64) {
let refresh = table
.entry("refresh".to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
if !refresh.is_table() {
*refresh = toml::Value::Table(toml::map::Map::new());
}
let refresh_table = refresh
.as_table_mut()
.expect("set_refresh_interval: 위에서 테이블로 보장했으므로 항상 Some");
refresh_table.insert(
"interval_seconds".to_string(),
toml::Value::Integer(interval as i64),
);
}
fn pulse_period_too_short(cfg: &Config, interval: u64) -> bool {
theme::samples_per_period(cfg, interval) < 6
}
fn warn_if_pulse_period_too_short(cfg: &Config, interval: u64) {
if pulse_period_too_short(cfg, interval) {
eprintln!(
"understatus: refreshInterval={interval}s에서는 테라코타 호흡이 끊길 수 있습니다\
(현재 pulse_period_seconds={}, 권장 >= {}).",
cfg.pulse.pulse_period_seconds,
interval.saturating_mul(6)
);
}
}
fn read_chain_command_from_config() -> Option<String> {
let config_path = config_toml_path()?;
let raw = std::fs::read_to_string(&config_path).ok()?;
let doc: toml::Value = toml::from_str(&raw).ok()?;
doc.get("chain")?
.get("chain_command")?
.as_str()
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn real_settings() -> Value {
json!({
"model": "claude-opus-4",
"permissions": { "allow": ["Bash"] },
"statusLine": {
"type": "command",
"command": "node $HOME/.claude/hud/lterm-omc-hud.mjs"
}
})
}
const UNDERSTATUS_PATH: &str = "/usr/local/bin/understatus";
const TEST_REFRESH_INTERVAL: u64 = 5;
#[test]
fn install_then_uninstall_restores_exactly() {
let original = real_settings();
let mut settings = original.clone();
let record = apply_install(&mut settings, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
let status_line = settings.get("statusLine").unwrap();
assert_eq!(
status_line.get("command").and_then(Value::as_str),
Some(UNDERSTATUS_PATH)
);
assert_eq!(
status_line.get("refreshInterval").and_then(Value::as_i64),
Some(5)
);
assert_eq!(
record.original_command.as_deref(),
Some("node $HOME/.claude/hud/lterm-omc-hud.mjs")
);
assert!(record.had_status_line);
assert_eq!(record.original_refresh_interval, None);
assert_eq!(settings.get("model"), original.get("model"));
assert_eq!(settings.get("permissions"), original.get("permissions"));
apply_uninstall(&mut settings, &record);
assert_eq!(
settings, original,
"uninstall 후 settings는 원본과 정확히 같아야 한다"
);
}
#[test]
fn install_is_idempotent() {
let mut once = real_settings();
apply_install(&mut once, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
let mut twice = real_settings();
apply_install(&mut twice, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
apply_install(&mut twice, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
assert_eq!(
once, twice,
"두 번 설치한 결과는 한 번 설치한 결과와 같아야 한다(이중 래핑 금지)"
);
let mut detect = real_settings();
apply_install(&mut detect, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
let second = apply_install(&mut detect, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
assert_eq!(second.original_command, None);
}
#[test]
fn install_without_status_line_then_uninstall_removes_key() {
let original = json!({ "model": "claude-opus-4" });
let mut settings = original.clone();
let record = apply_install(&mut settings, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
assert!(!record.had_status_line);
assert_eq!(record.original_command, None);
let status_line = settings.get("statusLine").unwrap();
assert_eq!(
status_line.get("command").and_then(Value::as_str),
Some(UNDERSTATUS_PATH)
);
assert_eq!(
status_line.get("refreshInterval").and_then(Value::as_i64),
Some(5)
);
apply_uninstall(&mut settings, &record);
assert_eq!(
settings, original,
"statusLine이 없던 원본은 uninstall 후 statusLine 키가 없어야 한다"
);
}
#[test]
fn install_preserves_and_restores_existing_refresh_interval() {
let original = json!({
"statusLine": {
"type": "command",
"command": "node $HOME/.claude/hud/lterm-omc-hud.mjs",
"refreshInterval": 5
}
});
let mut settings = original.clone();
let record = apply_install(&mut settings, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
assert_eq!(record.original_refresh_interval, Some(json!(5)));
assert_eq!(
settings
.get("statusLine")
.and_then(|s| s.get("refreshInterval"))
.and_then(Value::as_i64),
Some(5)
);
apply_uninstall(&mut settings, &record);
assert_eq!(
settings, original,
"기존 refreshInterval=5는 uninstall 후 정확히 5로 복원되어야 한다"
);
}
#[test]
fn install_preserves_unknown_status_line_keys() {
let original = json!({
"statusLine": {
"type": "command",
"command": "node old.mjs",
"customExtra": "keep-me"
}
});
let mut settings = original.clone();
apply_install(&mut settings, UNDERSTATUS_PATH, TEST_REFRESH_INTERVAL);
assert_eq!(
settings
.get("statusLine")
.and_then(|s| s.get("customExtra"))
.and_then(Value::as_str),
Some("keep-me")
);
}
#[test]
fn install_writes_theme_and_interval_in_single_doc() {
let existing = "[chain]\nchain_command = \"node old.mjs\"\n";
let serialized = edit_config_doc_str(Some(existing), |table| {
set_chain_command(table, "node old.mjs");
table.insert(
"theme".to_string(),
toml::Value::String("ember".to_string()),
);
set_refresh_interval(table, 7);
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed.get("theme").and_then(toml::Value::as_str),
Some("ember")
);
assert_eq!(
parsed
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(7)
);
assert_eq!(
parsed
.get("chain")
.and_then(|t| t.get("chain_command"))
.and_then(toml::Value::as_str),
Some("node old.mjs")
);
}
#[test]
fn set_section_keys_overwrite_non_table_section() {
let existing = "chain = \"corrupted\"\nrefresh = 42\n";
let serialized = edit_config_doc_str(Some(existing), |table| {
set_chain_command(table, "node old.mjs");
set_refresh_interval(table, 9);
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed
.get("chain")
.and_then(|t| t.get("chain_command"))
.and_then(toml::Value::as_str),
Some("node old.mjs")
);
assert_eq!(
parsed
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(9)
);
}
#[test]
fn edit_config_doc_str_creates_from_none() {
let serialized = edit_config_doc_str(None, |table| {
table.insert(
"theme".to_string(),
toml::Value::String("vivid".to_string()),
);
set_refresh_interval(table, 5);
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed.get("theme").and_then(toml::Value::as_str),
Some("vivid")
);
assert_eq!(
parsed
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(5)
);
}
#[test]
fn edit_config_doc_str_preserves_unrelated_keys() {
let existing = "[cpu]\nload_glyphs = [\"a\", \"b\"]\n";
let serialized = edit_config_doc_str(Some(existing), |table| {
table.insert("theme".to_string(), toml::Value::String("mono".to_string()));
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed.get("theme").and_then(toml::Value::as_str),
Some("mono")
);
let glyphs = parsed
.get("cpu")
.and_then(|t| t.get("load_glyphs"))
.and_then(toml::Value::as_array)
.expect("load_glyphs 보존");
assert_eq!(glyphs.len(), 2);
}
#[test]
fn set_theme_replaces_only_theme_key() {
let existing =
"theme = \"calm\"\n[chain]\nchain_command = \"x\"\n[refresh]\ninterval_seconds = 10\n";
let serialized = edit_config_doc_str(Some(existing), |table| {
table.insert(
"theme".to_string(),
toml::Value::String("vivid".to_string()),
);
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed.get("theme").and_then(toml::Value::as_str),
Some("vivid")
);
assert_eq!(
parsed
.get("chain")
.and_then(|t| t.get("chain_command"))
.and_then(toml::Value::as_str),
Some("x")
);
assert_eq!(
parsed
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(10)
);
}
#[test]
fn set_theme_rejects_unknown() {
let result = set_theme("does-not-exist");
let error = result.expect_err("미지 테마는 Err");
let message = format!("{error}");
assert!(message.contains("does-not-exist"), "에러에 테마 이름 포함");
assert!(message.contains("calm"), "에러에 사용 가능 목록 포함");
}
#[test]
fn install_rejects_unknown_theme() {
let error = validate_theme("bogus").expect_err("미지 테마는 Err");
let message = format!("{error}");
assert!(message.contains("bogus"), "에러에 미지 테마 이름 포함");
assert!(message.contains("calm"), "에러에 사용 가능 목록 포함");
for (name, _) in themes::catalog() {
assert!(validate_theme(name).is_ok(), "출시 테마 {name}은 통과해야");
}
}
#[test]
fn theme_command_does_not_change_interval() {
let existing = "theme = \"calm\"\n[refresh]\ninterval_seconds = 12\n";
let serialized = edit_config_doc_str(Some(existing), |table| {
table.insert(
"theme".to_string(),
toml::Value::String("ember".to_string()),
);
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(12),
"theme 교체 후에도 interval 불변"
);
}
#[test]
fn install_mirrors_interval_to_both_files() {
let mut settings = real_settings();
apply_install(&mut settings, UNDERSTATUS_PATH, 5);
assert_eq!(
settings
.get("statusLine")
.and_then(|s| s.get("refreshInterval"))
.and_then(Value::as_i64),
Some(5)
);
let serialized = edit_config_doc_str(None, |table| {
set_refresh_interval(table, 5);
Ok(())
})
.expect("변환 성공");
let parsed: toml::Value = toml::from_str(&serialized).expect("재파싱 성공");
assert_eq!(
parsed
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(5)
);
}
#[test]
fn pulse_period_too_short_cases() {
let mut cfg = Config::default();
cfg.pulse.pulse_period_seconds = 30;
assert!(!pulse_period_too_short(&cfg, 5), "30/5=6 → false");
assert!(pulse_period_too_short(&cfg, 6), "30/6=5 → true");
assert!(pulse_period_too_short(&cfg, 10), "30/10=3 → true");
assert!(!pulse_period_too_short(&cfg, 0), "30/1=30(.max(1)) → false");
cfg.pulse.pulse_period_seconds = 60;
assert!(!pulse_period_too_short(&cfg, 10), "60/10=6 → false");
}
#[test]
fn pulse_period_too_short_uses_injected_cfg() {
let mut custom = Config::default();
custom.pulse.pulse_period_seconds = 12;
assert!(pulse_period_too_short(&custom, 3));
assert!(!pulse_period_too_short(&Config::default(), 3));
let mut long = Config::default();
long.pulse.pulse_period_seconds = 60;
assert!(!pulse_period_too_short(&long, 8));
assert!(pulse_period_too_short(&Config::default(), 8));
}
#[test]
fn existing_interval_extracts_user_value() {
assert_eq!(
existing_interval(Some("[refresh]\ninterval_seconds = 10")),
Some(10)
);
assert_eq!(
existing_interval(Some("[pulse]\npulse_period_seconds = 30")),
None
);
assert_eq!(existing_interval(Some("")), None);
assert_eq!(existing_interval(None), None);
assert_eq!(existing_interval(Some("not valid toml ===")), None);
}
#[test]
fn existing_interval_rejects_non_positive() {
assert_eq!(
existing_interval(Some("[refresh]\ninterval_seconds = -1")),
None
);
assert_eq!(
existing_interval(Some("[refresh]\ninterval_seconds = 0")),
None
);
assert_eq!(
existing_interval(Some("[refresh]\ninterval_seconds = 1")),
Some(1)
);
}
static CONFIG_PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn unique_temp_config_path(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("understatus-test-{tag}-{pid}-{n}.toml"))
}
#[test]
fn edit_config_doc_disk_roundtrip() {
let _guard = CONFIG_PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let path = unique_temp_config_path("doc");
let prev = std::env::var_os("UNDERSTATUS_CONFIG");
std::env::set_var("UNDERSTATUS_CONFIG", &path);
let _ = std::fs::remove_file(&path);
edit_config_doc(|table| {
table.insert(
"theme".to_string(),
toml::Value::String("vivid".to_string()),
);
set_refresh_interval(table, 5);
Ok(())
})
.expect("디스크 write 성공");
let raw1 = std::fs::read_to_string(&path).expect("재read 성공");
let parsed1: toml::Value = toml::from_str(&raw1).expect("재파싱");
assert_eq!(
parsed1.get("theme").and_then(toml::Value::as_str),
Some("vivid")
);
edit_config_doc(|table| {
table.insert(
"theme".to_string(),
toml::Value::String("vivid".to_string()),
);
set_refresh_interval(table, 5);
Ok(())
})
.expect("재write 성공");
let raw2 = std::fs::read_to_string(&path).expect("재read 성공");
assert_eq!(raw1, raw2, "멱등 재write는 동일 결과");
edit_config_doc(|table| {
table.insert(
"theme".to_string(),
toml::Value::String("ember".to_string()),
);
Ok(())
})
.expect("부분 변경 성공");
let raw3 = std::fs::read_to_string(&path).expect("재read 성공");
let parsed3: toml::Value = toml::from_str(&raw3).expect("재파싱");
assert_eq!(
parsed3.get("theme").and_then(toml::Value::as_str),
Some("ember")
);
assert_eq!(
parsed3
.get("refresh")
.and_then(|t| t.get("interval_seconds"))
.and_then(toml::Value::as_integer),
Some(5),
"무관 키(refresh) 보존"
);
let _ = std::fs::remove_file(&path);
match prev {
Some(value) => std::env::set_var("UNDERSTATUS_CONFIG", value),
None => std::env::remove_var("UNDERSTATUS_CONFIG"),
}
}
#[test]
fn existing_interval_disk_roundtrip() {
let _guard = CONFIG_PATH_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let path = unique_temp_config_path("interval");
let prev = std::env::var_os("UNDERSTATUS_CONFIG");
std::env::set_var("UNDERSTATUS_CONFIG", &path);
std::fs::write(&path, "[refresh]\ninterval_seconds = 10\n").expect("fixture write");
let raw = read_existing_config_str();
assert_eq!(existing_interval(raw.as_deref()), Some(10));
let _ = std::fs::remove_file(&path);
match prev {
Some(value) => std::env::set_var("UNDERSTATUS_CONFIG", value),
None => std::env::remove_var("UNDERSTATUS_CONFIG"),
}
}
}