use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Map, Value};
use crate::config;
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() -> Result<()> {
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);
if let Some(command) = original_command {
merge_chain_command(&command)?;
}
let refresh_interval = config::load_config().refresh.interval_seconds;
let _record = apply_install(&mut settings, &understatus_path, refresh_interval);
write_pretty_json(&settings_path, &settings)?;
Ok(())
}
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> {
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 merge_chain_command(command: &str) -> Result<()> {
let Some(config_path) = config_toml_path() else {
return Err(anyhow!("config.toml 경로를 확인할 수 없습니다"));
};
let mut doc: toml::Value = match std::fs::read_to_string(&config_path) {
Ok(raw) => toml::from_str(&raw)
.with_context(|| format!("config.toml 파싱 실패: {}", config_path.display()))?,
Err(_) => toml::Value::Table(toml::map::Map::new()),
};
let table = doc
.as_table_mut()
.ok_or_else(|| anyhow!("config.toml 최상위가 테이블이 아닙니다"))?;
let chain = table
.entry("chain".to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
let chain_table = chain
.as_table_mut()
.ok_or_else(|| anyhow!("config.toml [chain]이 테이블이 아닙니다"))?;
chain_table.insert(
"chain_command".to_string(),
toml::Value::String(command.to_string()),
);
if let Some(dir) = config_path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("config 디렉터리 생성 실패: {}", dir.display()))?;
}
let serialized = toml::to_string_pretty(&doc).context("config.toml 직렬화 실패")?;
std::fs::write(&config_path, serialized)
.with_context(|| format!("config.toml 쓰기 실패: {}", config_path.display()))?;
Ok(())
}
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")
);
}
}