mod chain;
mod claude;
mod codex;
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") => match parse_render_args(&args) {
Ok(render_args) => {
run_render_pipeline(render_args.source, render_args.oneline);
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("understatus: {message}");
ExitCode::FAILURE
}
},
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("pulse") => run_pulse(&args),
Some("--help") | Some("-h") => {
print_help();
ExitCode::SUCCESS
}
Some("--version") | Some("-V") => {
print_version();
ExitCode::SUCCESS
}
Some(other) => {
eprintln!("understatus: 알 수 없는 서브커맨드 '{other}'. --help 참조.");
ExitCode::FAILURE
}
}
}
fn has_extra_args(args: &[String]) -> bool {
args.len() > 2
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Source {
Claude,
Lterm,
}
struct RenderArgs {
source: Source,
oneline: bool,
}
fn parse_render_args(args: &[String]) -> Result<RenderArgs, String> {
let mut source = Source::Claude;
let mut oneline = false;
let mut index = if args.first().map(String::as_str) == Some("render") {
1
} else {
0
};
while index < args.len() {
match args[index].as_str() {
"--source" => {
let value = args
.get(index + 1)
.ok_or_else(|| "--source 뒤에 값이 필요합니다(claude|lterm).".to_string())?;
source = parse_source(value)?;
index += 2;
}
"--oneline" => {
oneline = true;
index += 1;
}
other => {
return Err(format!("알 수 없는 render 옵션 '{other}'. --help 참조."));
}
}
}
Ok(RenderArgs { source, oneline })
}
fn parse_source(value: &str) -> Result<Source, String> {
match value {
"claude" => Ok(Source::Claude),
"lterm" => Ok(Source::Lterm),
other => Err(format!(
"알 수 없는 source '{other}'. 사용 가능: claude|lterm."
)),
}
}
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 {
if has_extra_args(args) {
eprintln!(
"understatus: theme 명령은 테마 이름 하나만 받습니다. 사용법: understatus theme <name>"
);
return ExitCode::FAILURE;
}
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 run_pulse(args: &[String]) -> ExitCode {
if has_extra_args(args) {
eprintln!("understatus: pulse 명령은 스타일 인자 하나만 받습니다. 사용법: understatus pulse <calm|flash|hue|swap>");
return ExitCode::FAILURE;
}
match args.get(1) {
Some(style) => match install::set_pulse_style(style) {
Ok(()) => {
eprintln!("understatus: 펄스 스타일을 '{style}'로 변경했습니다.");
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("understatus: 펄스 스타일 변경 실패: {error:#}");
ExitCode::FAILURE
}
},
None => {
let current = config::load_config().pulse.pulse_style;
println!("understatus: 현재 펄스 스타일은 '{current}'입니다.");
println!("사용법: understatus pulse <calm|flash|hue|swap>");
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}");
}
}
const CONTEXT_NATIVE_CACHE: &str = "ctx_native";
const CONTEXT_HOLD_TTL_SECONDS: u64 = 600;
fn resolve_claude_context(claude_input: &mut claude::ClaudeInput, session_key: &str, now_ms: u128) {
let held_native = read_held_native_ctx(session_key, now_ms);
let resolution = claude::resolve_context_percent(
claude_input.context_used_percentage,
claude_input.context_fallback_percentage,
held_native,
);
claude_input.context_used_percentage = resolution.display;
if let Some(native) = resolution.persist_native {
chain::write_session_named_cache(
session_key,
CONTEXT_NATIVE_CACHE,
now_ms,
&format!("{native}"),
);
}
}
fn read_held_native_ctx(session_key: &str, now_ms: u128) -> Option<f64> {
let entry = chain::read_session_named_cache(session_key, CONTEXT_NATIVE_CACHE);
interpret_held_native_ctx(entry, now_ms)
}
fn interpret_held_native_ctx(entry: Option<(u128, String)>, now_ms: u128) -> Option<f64> {
let (written_ms, payload) = entry?;
if !chain::is_named_cache_fresh(written_ms, now_ms, CONTEXT_HOLD_TTL_SECONDS) {
return None;
}
payload
.trim()
.parse::<f64>()
.ok()
.filter(|percent| percent.is_finite() && *percent > 0.0 && *percent <= 100.0)
}
fn run_render_pipeline(source: Source, oneline: bool) {
let raw_stdin = read_stdin();
let mut claude_input = match source {
Source::Claude => claude::parse_claude_input(&raw_stdin),
Source::Lterm => claude::parse_lterm_input(&raw_stdin),
};
let session_key = chain::sanitize_session_key(claude_input.session_id.as_deref().unwrap_or(""));
let cfg = config::load_config();
if source == Source::Lterm {
codex::maybe_enrich(&mut claude_input, &cfg);
}
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);
if source == Source::Claude {
resolve_claude_context(&mut claude_input, &session_key, now_ms);
}
let self_segment = render::render(&claude_input, &snapshot, &cfg, now_ms, pulse_on);
if oneline {
print!("{self_segment}");
let _ = std::io::stdout().flush();
return;
}
let chain_output = match (source, cfg.chain.chain_command.as_deref()) {
(Source::Claude, Some(command)) if !command.is_empty() => {
chain::run_chain(command, &raw_stdin, &cfg, &session_key)
}
_ => String::new(),
};
let chain_output = if cfg.chain.strip_chain_ctx {
chain::strip_chained_context(&chain_output)
} else {
chain_output
};
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 stdin JSON을 읽어 statusline 한 줄을 출력(기본 render)\n\
\x20 understatus render [옵션] render 옵션과 함께 statusline 한 줄을 출력\n\
\x20 understatus install [옵션] 기존 statusLine을 보존(체이닝)하며 비파괴 설치\n\
\x20 understatus uninstall 원본 설정을 정확 복원하며 제거\n\
\x20 understatus theme <name> 설치 후 테마 교체(config.toml만 수정)\n\
\x20 understatus themes 사용 가능한 테마 목록 출력\n\
\x20 understatus pulse <style> 펄스 스타일 교체(calm|flash|hue|swap, config.toml만 수정)\n\
\x20 understatus --help 이 도움말 출력\n\
\x20 understatus --version 버전 출력\n\
\n\
render 옵션(understatus render 뒤에 사용):\n\
\x20 --source <s> 입력 소스(claude|lterm). 미지정 시 claude.\n\
\x20 --oneline chain 없이 코어 한 줄만 후행 개행 없이 출력(status row용).\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;
#[test]
fn has_extra_args_detects_surplus() {
assert!(!has_extra_args(&["pulse".to_string()]));
assert!(!has_extra_args(&["pulse".to_string(), "hue".to_string()]));
assert!(has_extra_args(&[
"pulse".to_string(),
"hue".to_string(),
"typo".to_string()
]));
}
fn render_argv(rest: &[&str]) -> Vec<String> {
let mut v = vec!["render".to_string()];
v.extend(rest.iter().map(|s| s.to_string()));
v
}
#[test]
fn parse_render_args_empty_is_default() {
let parsed = parse_render_args(&[]).expect("파싱 성공");
assert_eq!(parsed.source, Source::Claude);
assert!(!parsed.oneline);
}
#[test]
fn parse_render_args_bare_render_is_default() {
let parsed = parse_render_args(&render_argv(&[])).expect("파싱 성공");
assert_eq!(parsed.source, Source::Claude);
assert!(!parsed.oneline);
}
#[test]
fn parse_render_args_lterm_oneline() {
let parsed = parse_render_args(&render_argv(&["--source", "lterm", "--oneline"]))
.expect("파싱 성공");
assert_eq!(parsed.source, Source::Lterm);
assert!(parsed.oneline);
}
#[test]
fn parse_render_args_order_independent() {
let parsed = parse_render_args(&render_argv(&["--oneline", "--source", "lterm"]))
.expect("파싱 성공");
assert_eq!(parsed.source, Source::Lterm);
assert!(parsed.oneline);
}
#[test]
fn parse_render_args_explicit_claude() {
let parsed = parse_render_args(&render_argv(&["--source", "claude"])).expect("파싱 성공");
assert_eq!(parsed.source, Source::Claude);
assert!(!parsed.oneline);
}
#[test]
fn parse_render_args_rejects_unknown_source() {
assert!(parse_render_args(&render_argv(&["--source", "bogus"])).is_err());
}
#[test]
fn parse_render_args_rejects_missing_source_value() {
assert!(parse_render_args(&render_argv(&["--source"])).is_err());
}
#[test]
fn parse_render_args_rejects_unknown_flag() {
assert!(parse_render_args(&render_argv(&["--bogus"])).is_err());
}
#[test]
fn parse_render_args_duplicate_source_last_wins() {
let parsed = parse_render_args(&render_argv(&["--source", "claude", "--source", "lterm"]))
.expect("파싱 성공");
assert_eq!(parsed.source, Source::Lterm);
}
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("invalid_theme").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);
}
#[test]
fn interpret_held_missing_entry_is_none() {
assert_eq!(interpret_held_native_ctx(None, 10_000), None);
}
#[test]
fn interpret_held_roundtrips_writer_format() {
for value in [86.0_f64, 33.7, 100.0, 12.5, 0.5] {
let payload = format!("{value}");
assert_eq!(
interpret_held_native_ctx(Some((1_000, payload)), 1_000),
Some(value),
"값 {value} 라운드트립 실패",
);
}
}
#[test]
fn interpret_held_respects_ttl_boundary() {
let ttl_ms = (CONTEXT_HOLD_TTL_SECONDS as u128) * 1_000;
let at_ttl = interpret_held_native_ctx(Some((1_000, "86".to_string())), 1_000 + ttl_ms);
assert_eq!(at_ttl, Some(86.0), "경계(정확히 TTL)는 유지");
let past_ttl =
interpret_held_native_ctx(Some((1_000, "86".to_string())), 1_000 + ttl_ms + 1);
assert_eq!(past_ttl, None, "TTL 초과는 stale → None");
}
#[test]
fn interpret_held_clock_skew_is_stale() {
assert_eq!(
interpret_held_native_ctx(Some((2_000, "86".to_string())), 1_000),
None
);
}
#[test]
fn interpret_held_rejects_garbage_and_nonfinite() {
for bad in ["abc", "", "NaN", "inf", "-inf"] {
assert_eq!(
interpret_held_native_ctx(Some((1_000, bad.to_string())), 1_000),
None,
"payload {bad:?}는 None이어야 함",
);
}
}
#[test]
fn interpret_held_rejects_out_of_range() {
for bad in ["150", "101", "0", "-5", "1e24"] {
assert_eq!(
interpret_held_native_ctx(Some((1_000, bad.to_string())), 1_000),
None,
"범위 밖 payload {bad:?}는 None이어야 함(손상 캐시 → fallback 저하)",
);
}
assert_eq!(
interpret_held_native_ctx(Some((1_000, "100".to_string())), 1_000),
Some(100.0)
);
assert_eq!(
interpret_held_native_ctx(Some((1_000, "0.5".to_string())), 1_000),
Some(0.5)
);
}
#[test]
fn held_native_cache_roundtrip_through_session_cache() {
let base =
std::env::temp_dir().join(format!("understatus-ctxnative-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let session_key = "ctx-roundtrip-session";
let now_ms: u128 = 1_000_000;
let native = 86.4_f64;
chain::write_session_named_cache_in(
&base,
session_key,
CONTEXT_NATIVE_CACHE,
now_ms,
&format!("{native}"),
);
let entry = chain::read_session_named_cache_in(&base, session_key, CONTEXT_NATIVE_CACHE);
assert_eq!(
interpret_held_native_ctx(entry, now_ms),
Some(native),
"세션 캐시 라운드트립으로 직전 native가 복원되어야 함",
);
let other =
chain::read_session_named_cache_in(&base, "other-session", CONTEXT_NATIVE_CACHE);
assert_eq!(interpret_held_native_ctx(other, now_ms), None);
let _ = std::fs::remove_dir_all(&base);
}
}