mod chain;
mod claude;
mod config;
mod install;
mod render;
mod system;
mod theme;
mod themes;
use std::io::{BufRead, IsTerminal, Read, Write};
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let subcommand = args.first().map(String::as_str);
match subcommand {
None | Some("render") => {
run_render_pipeline();
ExitCode::SUCCESS
}
Some("install") => run_install(&args),
Some("uninstall") => match install::uninstall() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("understatus: 제거 실패: {error:#}");
ExitCode::FAILURE
}
},
Some("theme") => run_theme(&args),
Some("themes") => {
print_themes();
ExitCode::SUCCESS
}
Some("--help") | Some("-h") => {
print_help();
ExitCode::SUCCESS
}
Some("--version") | Some("-V") => {
print_version();
ExitCode::SUCCESS
}
Some(other) => {
eprintln!("understatus: 알 수 없는 서브커맨드 '{other}'. --help 참조.");
ExitCode::FAILURE
}
}
}
const DEFAULT_THEME: &str = "calm";
const DEFAULT_INTERVAL: u64 = 5;
const MAX_PROMPT_RETRIES: u32 = 3;
struct InstallArgs {
interval: Option<u64>,
theme: Option<String>,
assume_yes: bool,
}
fn run_install(args: &[String]) -> ExitCode {
let install_args = match parse_install_args(args) {
Ok(parsed) => parsed,
Err(message) => {
eprintln!("understatus: {message}");
return ExitCode::FAILURE;
}
};
let existing = install::existing_interval(install::read_existing_config_str().as_deref());
let is_tty = std::io::stdin().is_terminal();
let stdin = std::io::stdin();
let mut reader = stdin.lock();
let stderr = std::io::stderr();
let mut writer = stderr.lock();
let (interval, theme) =
resolve_install_params(&install_args, &mut reader, &mut writer, is_tty, existing);
match install::install(interval, &theme) {
Ok(()) => {
eprintln!("understatus: 설치 완료(theme='{theme}', refreshInterval={interval}s).");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("understatus: 설치 실패: {error:#}");
ExitCode::FAILURE
}
}
}
fn run_theme(args: &[String]) -> ExitCode {
match args.get(1) {
Some(name) => match install::set_theme(name) {
Ok(()) => {
eprintln!("understatus: theme를 '{name}'로 변경했습니다.");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("understatus: 테마 변경 실패: {error:#}");
ExitCode::FAILURE
}
},
None => {
let current = config::load_config().theme;
println!("understatus: 현재 테마는 '{current}'입니다.");
println!("사용법: understatus theme <name> (목록: understatus themes)");
ExitCode::SUCCESS
}
}
}
fn parse_install_args(args: &[String]) -> Result<InstallArgs, String> {
let mut interval: Option<u64> = None;
let mut theme: Option<String> = None;
let mut assume_yes = false;
let mut index = 1; while index < args.len() {
match args[index].as_str() {
"--interval" => {
let raw = args
.get(index + 1)
.ok_or_else(|| "--interval 뒤에 값이 필요합니다(정수 ≥ 1).".to_string())?;
interval = Some(parse_interval(raw)?);
index += 2;
}
"--theme" => {
let value = args
.get(index + 1)
.ok_or_else(|| "--theme 뒤에 테마 이름이 필요합니다.".to_string())?;
theme = Some(value.clone());
index += 2;
}
"--yes" | "-y" => {
assume_yes = true;
index += 1;
}
other => {
return Err(format!("알 수 없는 install 옵션 '{other}'. --help 참조."));
}
}
}
Ok(InstallArgs {
interval,
theme,
assume_yes,
})
}
fn parse_interval(raw: &str) -> Result<u64, String> {
let value: u64 = raw
.trim()
.parse()
.map_err(|_| format!("interval은 정수여야 합니다(받은 값: '{raw}')."))?;
if value < 1 {
return Err("interval은 1 이상이어야 합니다.".to_string());
}
Ok(value)
}
fn parse_interval_prompt(line: &str) -> Result<Option<u64>, String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
parse_interval(trimmed).map(Some)
}
fn parse_theme_prompt(line: &str) -> Result<String, String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(DEFAULT_THEME.to_string());
}
let catalog = themes::catalog();
if let Ok(number) = trimmed.parse::<usize>() {
if number >= 1 && number <= catalog.len() {
return Ok(catalog[number - 1].0.to_string());
}
return Err(format!("번호는 1~{} 범위여야 합니다.", catalog.len()));
}
if themes::is_known(trimmed) {
return Ok(trimmed.to_string());
}
Err(format!(
"알 수 없는 테마 '{trimmed}'. 'themes' 명령으로 목록을 확인하세요."
))
}
fn resolve_install_params<R: BufRead, W: Write>(
args: &InstallArgs,
reader: &mut R,
writer: &mut W,
is_tty: bool,
existing_interval: Option<u64>,
) -> (u64, String) {
let interval = resolve_interval(args, reader, writer, is_tty, existing_interval);
let theme = resolve_theme(args, reader, writer, is_tty);
(interval, theme)
}
fn resolve_interval<R: BufRead, W: Write>(
args: &InstallArgs,
reader: &mut R,
writer: &mut W,
is_tty: bool,
existing_interval: Option<u64>,
) -> u64 {
if let Some(value) = args.interval {
return value;
}
let fallback = existing_interval.unwrap_or(DEFAULT_INTERVAL);
if !is_tty || args.assume_yes {
return fallback;
}
for _ in 0..MAX_PROMPT_RETRIES {
let _ = write!(writer, "Refresh interval in seconds [{fallback}]: ");
let _ = writer.flush();
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => return fallback, Ok(_) => match parse_interval_prompt(&line) {
Ok(None) => return fallback,
Ok(Some(value)) => return value,
Err(message) => {
let _ = writeln!(writer, " {message}");
}
},
}
}
fallback
}
fn resolve_theme<R: BufRead, W: Write>(
args: &InstallArgs,
reader: &mut R,
writer: &mut W,
is_tty: bool,
) -> String {
if let Some(value) = &args.theme {
return value.clone();
}
if !is_tty || args.assume_yes {
return DEFAULT_THEME.to_string();
}
for _ in 0..MAX_PROMPT_RETRIES {
write_theme_menu(writer);
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) | Err(_) => return DEFAULT_THEME.to_string(), Ok(_) => match parse_theme_prompt(&line) {
Ok(name) => return name,
Err(message) => {
let _ = writeln!(writer, " {message}");
}
},
}
}
DEFAULT_THEME.to_string()
}
fn write_theme_menu<W: Write>(writer: &mut W) {
let _ = writeln!(writer, "Theme:");
for (index, (name, tagline)) in themes::catalog().iter().enumerate() {
let _ = writeln!(writer, " {}) {:<6} {}", index + 1, name, tagline);
}
let _ = write!(writer, "Select [1]: ");
let _ = writer.flush();
}
fn print_themes() {
let current = config::load_config().theme;
println!("사용 가능한 테마 (현재: '{current}'):");
for (name, tagline) in themes::catalog() {
let marker = if *name == current { "*" } else { " " };
println!(" {marker} {name:<6} {tagline}");
}
}
fn run_render_pipeline() {
let raw_stdin = read_stdin();
let claude_input = claude::parse_claude_input(&raw_stdin);
let session_key = chain::sanitize_session_key(claude_input.session_id.as_deref().unwrap_or(""));
let cfg = config::load_config();
let snapshot = system::sample_system(&cfg, &session_key);
debug_assert!(
theme::samples_per_period(&cfg, cfg.refresh.interval_seconds) >= 6,
"펄스 지각성 불변식 위반: samples_per_period < 6 (pulse_period={}s, refreshInterval={}s)",
cfg.pulse.pulse_period_seconds,
cfg.refresh.interval_seconds
);
let prev_pulse_on = chain::read_prev_pulse_state(&session_key);
let now_ms = now_millis();
let pulse_on = theme::pulse_gate(snapshot.cpu_percent, prev_pulse_on, &cfg);
chain::write_pulse_state(pulse_on, &session_key);
let self_segment = render::render(&claude_input, &snapshot, &cfg, now_ms, pulse_on);
let chain_output = match cfg.chain.chain_command.as_deref() {
Some(command) if !command.is_empty() => {
chain::run_chain(command, &raw_stdin, &cfg, &session_key)
}
_ => String::new(),
};
let color_on = std::env::var_os("NO_COLOR").is_none() && cfg.color.mode != "none";
let line = render::compose_with_seam(
&self_segment,
&chain_output,
&cfg.chain.order,
&cfg,
color_on,
);
println!("{line}");
}
fn read_stdin() -> String {
let mut buffer = String::new();
let _ = std::io::stdin().read_to_string(&mut buffer);
buffer
}
fn now_millis() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_millis())
.unwrap_or(0)
}
fn print_help() {
println!(
"understatus {} — AI 코딩 CLI용 macOS statusline 애드온\n\
\n\
사용법:\n\
\x20 understatus [render] stdin JSON을 읽어 statusline 한 줄을 출력(기본)\n\
\x20 understatus install [옵션] 기존 statusLine을 보존(체이닝)하며 비파괴 설치\n\
\x20 understatus uninstall 원본 설정을 정확 복원하며 제거\n\
\x20 understatus theme <name> 설치 후 테마 교체(config.toml만 수정)\n\
\x20 understatus themes 사용 가능한 테마 목록 출력\n\
\x20 understatus --help 이 도움말 출력\n\
\x20 understatus --version 버전 출력\n\
\n\
install 옵션:\n\
\x20 --interval <N> refreshInterval 초(정수 ≥ 1). 미지정 시 프롬프트/승계/기본 5.\n\
\x20 --theme <name> 테마 이름. 미지정 시 프롬프트/기본 calm.\n\
\x20 --yes, -y 프롬프트 생략(TTY여도). 플래그/승계/기본값 사용.",
env!("CARGO_PKG_VERSION")
);
}
fn print_version() {
println!("understatus {}", env!("CARGO_PKG_VERSION"));
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn install_argv(rest: &[&str]) -> Vec<String> {
let mut v = vec!["install".to_string()];
v.extend(rest.iter().map(|s| s.to_string()));
v
}
#[test]
fn parse_install_args_all_flags() {
let args = install_argv(&["--interval", "10", "--theme", "vivid", "--yes"]);
let parsed = parse_install_args(&args).expect("파싱 성공");
assert_eq!(parsed.interval, Some(10));
assert_eq!(parsed.theme.as_deref(), Some("vivid"));
assert!(parsed.assume_yes);
}
#[test]
fn parse_install_args_empty_is_all_none() {
let parsed = parse_install_args(&install_argv(&[])).expect("파싱 성공");
assert_eq!(parsed.interval, None);
assert_eq!(parsed.theme, None);
assert!(!parsed.assume_yes);
}
#[test]
fn parse_install_args_short_yes() {
let parsed = parse_install_args(&install_argv(&["-y"])).expect("파싱 성공");
assert!(parsed.assume_yes);
}
#[test]
fn parse_install_args_rejects_unknown_flag() {
let result = parse_install_args(&install_argv(&["--bogus"]));
assert!(result.is_err());
}
#[test]
fn parse_install_args_rejects_missing_interval_value() {
let result = parse_install_args(&install_argv(&["--interval"]));
assert!(result.is_err());
}
#[test]
fn parse_interval_accepts_positive() {
assert_eq!(parse_interval("5"), Ok(5));
assert_eq!(parse_interval(" 12 "), Ok(12));
}
#[test]
fn parse_interval_rejects_zero_and_nonint() {
assert!(parse_interval("0").is_err());
assert!(parse_interval("-3").is_err());
assert!(parse_interval("abc").is_err());
}
#[test]
fn parse_interval_prompt_empty_is_none() {
assert_eq!(parse_interval_prompt(""), Ok(None));
assert_eq!(parse_interval_prompt(" \n"), Ok(None));
}
#[test]
fn parse_interval_prompt_valid_is_some() {
assert_eq!(parse_interval_prompt("8\n"), Ok(Some(8)));
}
#[test]
fn parse_interval_prompt_invalid_is_err() {
assert!(parse_interval_prompt("0").is_err());
assert!(parse_interval_prompt("x").is_err());
}
#[test]
fn parse_theme_prompt_empty_is_calm() {
assert_eq!(parse_theme_prompt(""), Ok("calm".to_string()));
assert_eq!(parse_theme_prompt("\n"), Ok("calm".to_string()));
}
#[test]
fn parse_theme_prompt_number_maps_to_name() {
assert_eq!(parse_theme_prompt("3"), Ok("vivid".to_string()));
assert_eq!(parse_theme_prompt("5\n"), Ok("emoji".to_string()));
}
#[test]
fn parse_theme_prompt_name_is_accepted() {
assert_eq!(parse_theme_prompt("ember"), Ok("ember".to_string()));
}
#[test]
fn parse_theme_prompt_invalid_is_err() {
assert!(parse_theme_prompt("99").is_err());
assert!(parse_theme_prompt("neon").is_err());
}
#[test]
fn resolve_install_params_flags_only_non_tty() {
let args = InstallArgs {
interval: Some(9),
theme: Some("mono".to_string()),
assume_yes: false,
};
let mut reader = Cursor::new(Vec::new());
let mut writer = Vec::new();
let (interval, theme) =
resolve_install_params(&args, &mut reader, &mut writer, false, None);
assert_eq!(interval, 9);
assert_eq!(theme, "mono");
}
#[test]
fn resolve_install_params_eof_falls_back() {
let args = InstallArgs {
interval: None,
theme: None,
assume_yes: false,
};
let mut reader = Cursor::new(Vec::new()); let mut writer = Vec::new();
let (interval, theme) = resolve_install_params(&args, &mut reader, &mut writer, true, None);
assert_eq!(interval, 5);
assert_eq!(theme, "calm");
}
#[test]
fn resolve_install_params_mixed_flag_and_prompt() {
let args = InstallArgs {
interval: Some(7),
theme: None,
assume_yes: false,
};
let mut reader = Cursor::new(b"vivid\n".to_vec());
let mut writer = Vec::new();
let (interval, theme) = resolve_install_params(&args, &mut reader, &mut writer, true, None);
assert_eq!(interval, 7, "interval은 플래그값(프롬프트 안 함)");
assert_eq!(theme, "vivid", "theme은 프롬프트값");
}
#[test]
fn resolve_install_params_inherits_interval_when_unset() {
let args = InstallArgs {
interval: None,
theme: None,
assume_yes: true,
};
let mut reader = Cursor::new(Vec::new());
let mut writer = Vec::new();
let (interval, _) =
resolve_install_params(&args, &mut reader, &mut writer, false, Some(10));
assert_eq!(interval, 10);
}
#[test]
fn resolve_install_params_flag_overrides_existing() {
let args = InstallArgs {
interval: Some(3),
theme: None,
assume_yes: true,
};
let mut reader = Cursor::new(Vec::new());
let mut writer = Vec::new();
let (interval, _) =
resolve_install_params(&args, &mut reader, &mut writer, false, Some(10));
assert_eq!(interval, 3);
}
#[test]
fn resolve_install_params_default_when_no_flag_no_existing() {
let args = InstallArgs {
interval: None,
theme: None,
assume_yes: true,
};
let mut reader = Cursor::new(Vec::new());
let mut writer = Vec::new();
let (interval, _) = resolve_install_params(&args, &mut reader, &mut writer, false, None);
assert_eq!(interval, 5);
}
#[test]
fn resolve_install_params_tty_empty_input_inherits() {
let args = InstallArgs {
interval: None,
theme: Some("calm".to_string()), assume_yes: false,
};
let mut reader = Cursor::new(b"\n".to_vec()); let mut writer = Vec::new();
let (interval, _) = resolve_install_params(&args, &mut reader, &mut writer, true, Some(10));
assert_eq!(interval, 10);
}
}