use crate::codex::CodexExtras;
use serde::Deserialize;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ClaudeInput {
pub model_display_name: Option<String>,
pub context_used_percentage: Option<f64>,
pub context_fallback_percentage: Option<f64>,
pub cwd: Option<String>,
pub git_branch: Option<String>,
pub cost_usd: Option<f64>,
pub session_id: Option<String>,
pub session_label: Option<String>,
pub codex: Option<CodexExtras>,
pub rate_5h_percent: Option<f64>,
pub rate_5h_countdown: Option<String>,
pub rate_weekly_percent: Option<f64>,
pub rate_weekly_countdown: Option<String>,
#[doc(hidden)]
pub internal_rate_5h_resets_at_raw: Option<f64>,
#[doc(hidden)]
pub internal_rate_weekly_resets_at_raw: Option<f64>,
}
pub fn parse_claude_input(raw: &str) -> ClaudeInput {
let trimmed = raw.trim();
if trimmed.is_empty() {
return ClaudeInput::default();
}
let raw_input: RawClaudeInput = match serde_json::from_str(trimmed) {
Ok(parsed) => parsed,
Err(_) => return ClaudeInput::default(),
};
let model_display_name = raw_input.model.and_then(|model| model.display_name);
let (context_used_percentage, context_fallback_percentage) = match raw_input.context_window {
Some(window) => (window.used_percentage, compute_context_fallback(&window)),
None => (None, None),
};
let cost_usd = raw_input.cost.and_then(|cost| cost.total_cost_usd);
let (cwd_from_workspace, git_branch) = match raw_input.workspace {
Some(workspace) => {
let branch = derive_git_branch(&workspace);
(workspace.current_dir, branch)
}
None => (None, None),
};
let rate = ParsedRateLimits::from_raw(raw_input.rate_limits);
ClaudeInput {
model_display_name,
context_used_percentage,
context_fallback_percentage,
cwd: raw_input.cwd.or(cwd_from_workspace),
git_branch,
cost_usd,
session_id: raw_input.session_id,
session_label: None,
codex: None,
rate_5h_percent: rate.five_hour_percent,
rate_5h_countdown: None,
rate_weekly_percent: rate.seven_day_percent,
rate_weekly_countdown: None,
internal_rate_5h_resets_at_raw: rate.five_hour_resets_at,
internal_rate_weekly_resets_at_raw: rate.seven_day_resets_at,
}
}
#[derive(Default)]
struct ParsedRateLimits {
five_hour_percent: Option<f64>,
five_hour_resets_at: Option<f64>,
seven_day_percent: Option<f64>,
seven_day_resets_at: Option<f64>,
}
impl ParsedRateLimits {
fn from_raw(raw: Option<RawRateLimits>) -> Self {
let limits = raw.unwrap_or_default();
let (five_hour_percent, five_hour_resets_at) = unpack_rate_window(limits.five_hour);
let (seven_day_percent, seven_day_resets_at) = unpack_rate_window(limits.seven_day);
Self {
five_hour_percent,
five_hour_resets_at,
seven_day_percent,
seven_day_resets_at,
}
}
}
fn unpack_rate_window(window: Option<RawRateWindow>) -> (Option<f64>, Option<f64>) {
match window {
Some(w) => (w.used_percentage, w.resets_at),
None => (None, None),
}
}
pub(crate) const RATE_5H_MAX_REMAINING_SECS: i64 = 18_000 + 600;
pub(crate) const RATE_WEEKLY_MAX_REMAINING_SECS: i64 = 604_800 + 3_600;
pub(crate) fn compute_remaining_secs(resets_at: f64, now_ms: u128, max_secs: i64) -> Option<i64> {
if !resets_at.is_finite() || now_ms == 0 {
return None;
}
let remaining = (resets_at as i64).checked_sub((now_ms / 1000) as i64)?;
if remaining <= 0 || remaining > max_secs {
return None;
}
Some(remaining)
}
pub(crate) fn format_reset_countdown(remaining_secs: i64) -> Option<String> {
if remaining_secs <= 0 {
return None;
}
if remaining_secs < 60 {
return Some("<1m".to_string());
}
if remaining_secs >= 86_400 {
let (days, hours) = (remaining_secs / 86_400, (remaining_secs % 86_400) / 3_600);
return Some(format!("{days}d{hours}h"));
}
let (hours, mins) = (remaining_secs / 3_600, (remaining_secs % 3_600) / 60);
Some(if hours == 0 {
format!("{mins}m")
} else {
format!("{hours}h{mins}m")
})
}
pub(crate) fn clamp_rate_percent(percent: f64) -> u32 {
if !percent.is_finite() || percent <= 0.0 {
return 0;
}
percent.round().min(100.0) as u32
}
fn compute_context_fallback(window: &RawContextWindow) -> Option<f64> {
let size = window.context_window_size?;
if size <= 0.0 {
return None;
}
let current_tokens = window
.current_usage
.as_ref()
.map(RawCurrentUsage::total_tokens)
.unwrap_or(0.0);
if current_tokens > 0.0 {
return Some(percent_of(current_tokens, size));
}
let total_input = window.total_input_tokens.unwrap_or(0.0);
if total_input > 0.0 {
return Some(percent_of(total_input, size));
}
None
}
fn percent_of(tokens: f64, size: f64) -> f64 {
((tokens / size) * 100.0).round().clamp(0.0, 100.0)
}
fn clamp_percent(percent: f64) -> f64 {
percent.clamp(0.0, 100.0)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextResolution {
pub display: Option<f64>,
pub persist_native: Option<f64>,
}
pub fn resolve_context_percent(
native: Option<f64>,
fallback: Option<f64>,
held_native: Option<f64>,
drop_tolerance: f64,
) -> ContextResolution {
let fallback = fallback
.filter(|p| p.is_finite() && *p >= 0.0)
.map(clamp_percent);
if let Some(positive) = native.filter(|p| p.is_finite() && *p > 0.0) {
let clamped = clamp_percent(positive);
return ContextResolution {
display: Some(clamped),
persist_native: Some(clamped),
};
}
if let Some(held) = held_native
.filter(|p| p.is_finite() && *p > 0.0)
.map(clamp_percent)
{
let real_drop = fallback.is_some_and(|fb| fb <= held - drop_tolerance);
if !real_drop {
return ContextResolution {
display: Some(held),
persist_native: None,
};
}
}
ContextResolution {
display: fallback.or_else(|| native.filter(|p| p.is_finite()).map(clamp_percent)),
persist_native: None,
}
}
pub fn parse_lterm_input(raw: &str) -> ClaudeInput {
let trimmed = raw.trim();
if trimmed.is_empty() {
return ClaudeInput::default();
}
let raw_input: RawLtermInput = match serde_json::from_str(trimmed) {
Ok(parsed) => parsed,
Err(_) => return ClaudeInput::default(),
};
let session_label = synthesize_session_key(&raw_input.session, &raw_input.pane);
let session_key = raw_input
.session_key
.filter(|key| !key.is_empty())
.or_else(|| session_label.clone());
let git_branch = raw_input
.cwd
.as_deref()
.and_then(derive_git_branch_from_cwd);
ClaudeInput {
model_display_name: raw_input.agent,
context_used_percentage: None,
context_fallback_percentage: None,
cwd: raw_input.cwd,
git_branch,
cost_usd: None,
session_id: session_key,
session_label,
codex: None,
rate_5h_percent: None,
rate_5h_countdown: None,
rate_weekly_percent: None,
rate_weekly_countdown: None,
internal_rate_5h_resets_at_raw: None,
internal_rate_weekly_resets_at_raw: None,
}
}
fn synthesize_session_key(session: &Option<String>, pane: &Option<String>) -> Option<String> {
let session = session.as_deref().filter(|value| !value.is_empty());
let pane = pane.as_deref().filter(|value| !value.is_empty());
match (session, pane) {
(Some(session), Some(pane)) => Some(format!("{session}/{pane}")),
(Some(session), None) => Some(session.to_string()),
(None, Some(pane)) => Some(pane.to_string()),
(None, None) => None,
}
}
fn derive_git_branch(workspace: &RawWorkspace) -> Option<String> {
let base_path = workspace
.git_worktree
.as_deref()
.or(workspace.repo.as_deref())?;
if !is_safe_base_path(base_path) {
return None;
}
if !std::path::Path::new(base_path).is_absolute() {
return None;
}
read_branch_from_git_dir(std::path::Path::new(base_path))
}
fn is_safe_base_path(base_path: &str) -> bool {
use std::path::{Component, Path};
if base_path.trim().is_empty() {
return false;
}
!Path::new(base_path)
.components()
.any(|component| matches!(component, Component::ParentDir))
}
const MAX_BRANCH_LEN: usize = 256;
const MAX_WALK_UP_DEPTH: usize = 64;
fn read_branch_from_git_dir(base_path: &std::path::Path) -> Option<String> {
use std::path::Path;
let head_path = base_path.join(".git").join("HEAD");
let canonical = std::fs::canonicalize(&head_path).ok()?;
if !canonical.ends_with(Path::new(".git").join("HEAD")) {
return None;
}
let contents = std::fs::read_to_string(&canonical).ok()?;
let trimmed = contents.trim();
let branch = trimmed.strip_prefix("ref: refs/heads/")?;
if branch.is_empty() || branch.len() > MAX_BRANCH_LEN || branch.chars().any(char::is_control) {
None
} else {
Some(branch.to_string())
}
}
fn derive_git_branch_from_cwd(cwd: &str) -> Option<String> {
if !is_safe_base_path(cwd) {
return None;
}
if !std::path::Path::new(cwd).is_absolute() {
return None;
}
let start = std::fs::canonicalize(cwd).ok()?;
find_git_root_dir(&start).and_then(|root| read_branch_from_git_dir(&root))
}
fn find_git_root_dir(start: &std::path::Path) -> Option<std::path::PathBuf> {
find_git_root_dir_capped(start, MAX_WALK_UP_DEPTH)
}
fn find_git_root_dir_capped(start: &std::path::Path, cap: usize) -> Option<std::path::PathBuf> {
for dir in start.ancestors().take(cap) {
if std::fs::symlink_metadata(dir.join(".git")).is_ok() {
return Some(dir.to_path_buf());
}
}
None
}
#[derive(Debug, Deserialize, Default)]
struct RawClaudeInput {
#[serde(default, deserialize_with = "deserialize_lenient_string")]
session_id: Option<String>,
#[serde(default, deserialize_with = "deserialize_lenient_string")]
cwd: Option<String>,
#[serde(default)]
model: Option<RawModel>,
#[serde(default)]
workspace: Option<RawWorkspace>,
#[serde(default)]
cost: Option<RawCost>,
#[serde(default)]
context_window: Option<RawContextWindow>,
#[serde(default, deserialize_with = "deserialize_lenient_rate_limits")]
rate_limits: Option<RawRateLimits>,
}
#[derive(Debug, Deserialize, Default)]
struct RawRateLimits {
#[serde(default, deserialize_with = "deserialize_lenient_rate_window")]
five_hour: Option<RawRateWindow>,
#[serde(default, deserialize_with = "deserialize_lenient_rate_window")]
seven_day: Option<RawRateWindow>,
}
#[derive(Debug, Deserialize, Default)]
struct RawRateWindow {
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
used_percentage: Option<f64>,
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
resets_at: Option<f64>,
}
fn deserialize_lenient_rate_limits<'de, D>(
deserializer: D,
) -> Result<Option<RawRateLimits>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?
.and_then(|value| serde_json::from_value(value).ok()))
}
fn deserialize_lenient_rate_window<'de, D>(
deserializer: D,
) -> Result<Option<RawRateWindow>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?
.and_then(|value| serde_json::from_value(value).ok()))
}
#[derive(Debug, Deserialize, Default)]
struct RawModel {
#[serde(default, deserialize_with = "deserialize_lenient_string")]
display_name: Option<String>,
#[serde(default, deserialize_with = "deserialize_lenient_string")]
#[allow(dead_code)]
id: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawWorkspace {
#[serde(default, deserialize_with = "deserialize_lenient_string")]
current_dir: Option<String>,
#[serde(default, deserialize_with = "deserialize_lenient_string")]
#[allow(dead_code)]
project_dir: Option<String>,
#[serde(default, deserialize_with = "deserialize_lenient_string")]
git_worktree: Option<String>,
#[serde(default, deserialize_with = "deserialize_lenient_string")]
repo: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawCost {
#[serde(default)]
total_cost_usd: Option<f64>,
}
fn deserialize_lenient_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?.and_then(|value| value.as_f64()))
}
fn deserialize_lenient_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?
.and_then(|value| value.as_str().map(str::to_string)))
}
fn deserialize_lenient_current_usage<'de, D>(
deserializer: D,
) -> Result<Option<RawCurrentUsage>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<serde_json::Value>::deserialize(deserializer)?
.and_then(|value| serde_json::from_value(value).ok()))
}
#[derive(Debug, Deserialize, Default)]
struct RawContextWindow {
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
used_percentage: Option<f64>,
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
context_window_size: Option<f64>,
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
total_input_tokens: Option<f64>,
#[serde(default, deserialize_with = "deserialize_lenient_current_usage")]
current_usage: Option<RawCurrentUsage>,
}
#[derive(Debug, Deserialize, Default)]
struct RawCurrentUsage {
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
input_tokens: Option<f64>,
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
cache_creation_input_tokens: Option<f64>,
#[serde(default, deserialize_with = "deserialize_lenient_f64")]
cache_read_input_tokens: Option<f64>,
}
impl RawCurrentUsage {
fn total_tokens(&self) -> f64 {
self.input_tokens.unwrap_or(0.0)
+ self.cache_creation_input_tokens.unwrap_or(0.0)
+ self.cache_read_input_tokens.unwrap_or(0.0)
}
}
#[derive(Debug, Deserialize, Default)]
struct RawLtermInput {
#[serde(default)]
#[allow(dead_code)]
source: Option<serde_json::Value>,
#[serde(default)]
#[allow(dead_code)]
version: Option<serde_json::Value>,
#[serde(default)]
session: Option<String>,
#[serde(default)]
pane: Option<String>,
#[serde(default)]
session_key: Option<String>,
#[serde(default)]
agent: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
#[allow(dead_code)]
cols: Option<serde_json::Value>,
#[serde(default)]
#[allow(dead_code)]
rows: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
fn unique_test_dir(label: &str) -> TestDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
TestDir(
std::env::temp_dir().join(format!("understatus-{label}-{}-{seq}", std::process::id())),
)
}
#[test]
fn test_dir_guard_cleans_up_on_panic() {
use std::sync::Mutex;
let captured_path: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let tmp = unique_test_dir("panic-cleanup");
std::fs::create_dir_all(&*tmp).expect("임시 디렉터리 생성 실패");
assert!(tmp.exists(), "panic 전엔 디렉터리가 존재해야 한다");
*captured_path.lock().expect("락 획득 실패") = Some(tmp.to_path_buf());
panic!("의도적 패닉 — 가드 cleanup 증명용");
}));
assert!(
result.is_err(),
"catch_unwind가 의도적 panic을 포착해야 한다"
);
let path = captured_path
.lock()
.expect("락 획득 실패")
.take()
.expect("패닉 전 경로가 캡처돼야 한다");
assert!(
!path.exists(),
"panic 언와인딩 후 가드 Drop이 디렉터리를 정리해 부재해야 한다: {path:?}"
);
}
#[test]
fn parses_normal_input() {
let raw = r#"{
"session_id": "sess-123",
"cwd": "/Users/me/proj",
"model": { "display_name": "Claude Opus", "id": "claude-opus" },
"workspace": { "current_dir": "/Users/me/proj", "repo": "myrepo" },
"cost": { "total_cost_usd": 0.42 },
"context_window": { "used_percentage": 37.5 }
}"#;
let input = parse_claude_input(raw);
assert_eq!(input.session_id.as_deref(), Some("sess-123"));
assert_eq!(input.cwd.as_deref(), Some("/Users/me/proj"));
assert_eq!(input.model_display_name.as_deref(), Some("Claude Opus"));
assert_eq!(input.cost_usd, Some(0.42));
assert_eq!(input.context_used_percentage, Some(37.5));
}
#[test]
fn null_context_window_yields_none() {
let raw = r#"{ "model": { "display_name": "M" }, "context_window": null }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, None);
assert_eq!(input.model_display_name.as_deref(), Some("M"));
}
#[test]
fn null_used_percentage_yields_none() {
let raw = r#"{ "context_window": { "used_percentage": null } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, None);
}
#[test]
fn fallback_from_current_usage_when_native_absent() {
let raw = r#"{ "context_window": {
"context_window_size": 1000000,
"current_usage": { "input_tokens": 100000, "cache_creation_input_tokens": 20000, "cache_read_input_tokens": 320000 }
} }"#;
let input = parse_claude_input(raw);
assert_eq!(
input.context_used_percentage, None,
"native 누락 → context_used_percentage None"
);
assert_eq!(input.context_fallback_percentage, Some(44.0));
}
#[test]
fn fallback_from_total_input_when_current_usage_zero() {
let raw = r#"{ "context_window": {
"context_window_size": 1000000,
"total_input_tokens": 450000,
"current_usage": { "input_tokens": 0 }
} }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_fallback_percentage, Some(45.0));
}
#[test]
fn fallback_none_without_window_size() {
let raw = r#"{ "context_window": { "current_usage": { "input_tokens": 500000 } } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_fallback_percentage, None);
}
#[test]
fn fallback_none_with_nonpositive_size() {
let zero = parse_claude_input(
r#"{ "context_window": { "context_window_size": 0, "total_input_tokens": 100 } }"#,
);
assert_eq!(zero.context_fallback_percentage, None);
let negative = parse_claude_input(
r#"{ "context_window": { "context_window_size": -5, "total_input_tokens": 100 } }"#,
);
assert_eq!(negative.context_fallback_percentage, None);
}
#[test]
fn fallback_none_with_zero_tokens() {
let raw = r#"{ "context_window": { "context_window_size": 1000000 } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_fallback_percentage, None);
}
#[test]
fn native_and_fallback_both_populated() {
let raw = r#"{ "context_window": {
"used_percentage": 86.0,
"context_window_size": 1000000,
"current_usage": { "input_tokens": 980000 }
} }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, Some(86.0));
assert_eq!(input.context_fallback_percentage, Some(98.0));
}
#[test]
fn fallback_clamps_to_100() {
let raw = r#"{ "context_window": {
"context_window_size": 1000,
"current_usage": { "input_tokens": 5000 }
} }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_fallback_percentage, Some(100.0));
}
#[test]
fn percent_of_rounds_and_clamps() {
assert_eq!(percent_of(334.0, 1000.0), 33.0);
assert_eq!(percent_of(336.0, 1000.0), 34.0);
assert_eq!(percent_of(2.0, 1.0), 100.0);
assert_eq!(percent_of(0.0, 1000.0), 0.0);
}
fn resolve_with_default_tolerance(
native: Option<f64>,
fallback: Option<f64>,
held: Option<f64>,
) -> ContextResolution {
resolve_context_percent(
native,
fallback,
held,
crate::config::DEFAULT_CONTEXT_DROP_TOLERANCE,
)
}
#[test]
fn resolve_prefers_positive_native_and_persists() {
let r = resolve_with_default_tolerance(Some(86.0), Some(98.0), Some(50.0));
assert_eq!(r.display, Some(86.0));
assert_eq!(r.persist_native, Some(86.0));
}
#[test]
fn resolve_holds_previous_native_on_transient_gap() {
let r = resolve_with_default_tolerance(None, Some(98.0), Some(86.0));
assert_eq!(r.display, Some(86.0), "직전 native 유지로 86↔98 튐 차단");
assert_eq!(r.persist_native, None, "유지 프레임은 재영속화하지 않음");
}
#[test]
fn resolve_uses_fallback_when_no_native_and_no_hold() {
let r = resolve_with_default_tolerance(None, Some(45.0), None);
assert_eq!(r.display, Some(45.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_yields_none_when_nothing_available() {
let r = resolve_with_default_tolerance(None, None, None);
assert_eq!(r.display, None);
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_zero_native_defers_to_fallback() {
let r = resolve_with_default_tolerance(Some(0.0), Some(45.0), None);
assert_eq!(r.display, Some(45.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_zero_native_shown_as_last_resort() {
let r = resolve_with_default_tolerance(Some(0.0), None, None);
assert_eq!(r.display, Some(0.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_rejects_nonfinite_native() {
let r = resolve_with_default_tolerance(Some(f64::NAN), None, Some(70.0));
assert_eq!(r.display, Some(70.0), "NaN native 무시 → hold 사용");
assert_eq!(r.persist_native, None);
let cold = resolve_with_default_tolerance(Some(f64::NAN), None, None);
assert_eq!(cold.display, None, "NaN은 raw native 표시 후보에서도 제외");
}
#[test]
fn resolve_breaks_hold_on_real_drop() {
let r = resolve_with_default_tolerance(None, Some(20.0), Some(86.0));
assert_eq!(r.display, Some(20.0), "급감은 즉시 반영(stale-high 방지)");
assert_eq!(r.persist_native, None, "토큰 fallback은 영속화하지 않음");
}
#[test]
fn resolve_holds_on_small_dip_within_tolerance() {
let r = resolve_with_default_tolerance(None, Some(78.0), Some(86.0));
assert_eq!(r.display, Some(86.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_drop_guard_boundary() {
assert_eq!(
resolve_with_default_tolerance(None, Some(74.0), Some(86.0)).display,
Some(74.0)
);
assert_eq!(
resolve_with_default_tolerance(None, Some(75.0), Some(86.0)).display,
Some(86.0)
);
}
#[test]
fn resolve_holds_when_no_fallback_to_compare() {
let r = resolve_with_default_tolerance(None, None, Some(86.0));
assert_eq!(r.display, Some(86.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_clamps_out_of_range_native() {
let r = resolve_with_default_tolerance(Some(150.0), None, None);
assert_eq!(r.display, Some(100.0));
assert_eq!(r.persist_native, Some(100.0), "클램프된 값만 영속화");
}
#[test]
fn resolve_clamps_negative_native_to_zero() {
let r = resolve_with_default_tolerance(Some(-5.0), None, None);
assert_eq!(r.display, Some(0.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_clamps_out_of_range_held() {
let r = resolve_with_default_tolerance(None, None, Some(150.0));
assert_eq!(r.display, Some(100.0));
assert_eq!(r.persist_native, None);
}
#[test]
fn resolve_rejects_nonpositive_or_nonfinite_held() {
assert_eq!(
resolve_with_default_tolerance(None, Some(45.0), Some(-5.0)).display,
Some(45.0)
);
assert_eq!(
resolve_with_default_tolerance(None, None, Some(0.0)).display,
None
);
assert_eq!(
resolve_with_default_tolerance(None, Some(30.0), Some(f64::NAN)).display,
Some(30.0)
);
}
#[test]
fn resolve_rejects_nonfinite_fallback() {
assert_eq!(
resolve_with_default_tolerance(None, Some(f64::NAN), None).display,
None
);
assert_eq!(
resolve_with_default_tolerance(None, Some(f64::INFINITY), None).display,
None
);
}
#[test]
fn resolve_clamps_out_of_range_fallback() {
let r = resolve_with_default_tolerance(None, Some(150.0), None);
assert_eq!(r.display, Some(100.0));
}
#[test]
fn resolve_normalized_fallback_does_not_break_hold() {
let r = resolve_with_default_tolerance(None, Some(-5.0), Some(86.0));
assert_eq!(r.display, Some(86.0), "음수 fallback은 hold를 깨지 못함");
assert_eq!(r.persist_native, None);
assert_eq!(
resolve_with_default_tolerance(None, Some(f64::NAN), Some(86.0)).display,
Some(86.0)
);
}
#[test]
fn resolve_drop_guard_uses_clamped_held() {
assert_eq!(
resolve_with_default_tolerance(None, Some(89.0), Some(150.0)).display,
Some(100.0),
"89 > 88 → 클램프된 held(100) 유지",
);
assert_eq!(
resolve_with_default_tolerance(None, Some(87.0), Some(150.0)).display,
Some(87.0),
"87 <= 88 → 유지를 깨고 fallback 표시",
);
}
#[test]
fn token_field_type_drift_preserves_other_fields() {
let raw = r#"{
"model": { "display_name": "Opus" },
"context_window": { "context_window_size": 1000000, "used_percentage": 86.0, "total_input_tokens": "oops" }
}"#;
let input = parse_claude_input(raw);
assert_eq!(
input.model_display_name.as_deref(),
Some("Opus"),
"model 보존"
);
assert_eq!(
input.context_used_percentage,
Some(86.0),
"used_percentage 보존"
);
assert_eq!(input.context_fallback_percentage, None);
}
#[test]
fn current_usage_token_drift_is_absorbed() {
let raw = r#"{
"model": { "display_name": "Opus" },
"context_window": { "context_window_size": 1000000, "current_usage": { "input_tokens": "big" } }
}"#;
let input = parse_claude_input(raw);
assert_eq!(input.model_display_name.as_deref(), Some("Opus"));
assert_eq!(
input.context_fallback_percentage, None,
"문자열 토큰은 0 취급 → fallback 없음"
);
}
#[test]
fn current_usage_wrong_object_type_is_absorbed() {
let raw = r#"{
"model": { "display_name": "Opus" },
"context_window": { "context_window_size": 1000000, "total_input_tokens": 450000, "current_usage": "nope" }
}"#;
let input = parse_claude_input(raw);
assert_eq!(input.model_display_name.as_deref(), Some("Opus"));
assert_eq!(input.context_fallback_percentage, Some(45.0));
}
#[test]
fn used_percentage_drift_preserves_fallback_and_model() {
let raw = r#"{
"model": { "display_name": "Opus" },
"context_window": { "used_percentage": "oops", "context_window_size": 1000000, "total_input_tokens": 450000 }
}"#;
let input = parse_claude_input(raw);
assert_eq!(
input.model_display_name.as_deref(),
Some("Opus"),
"model 보존"
);
assert_eq!(input.context_used_percentage, None, "문자열 native → None");
assert_eq!(
input.context_fallback_percentage,
Some(45.0),
"토큰 fallback 생존"
);
}
#[test]
fn window_size_drift_preserves_native() {
let raw = r#"{
"context_window": { "used_percentage": 80.0, "context_window_size": "oops", "current_usage": { "input_tokens": 500000 } }
}"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, Some(80.0), "native 보존");
assert_eq!(
input.context_fallback_percentage, None,
"분모 드리프트 → fallback 불가"
);
}
#[test]
fn workspace_repo_object_drift_preserves_all_segments() {
let raw = r#"{
"model": { "display_name": "Opus 4.8 (1M context)", "id": "claude-opus-4-8" },
"cwd": "/Users/me/proj",
"workspace": {
"current_dir": "/Users/me/proj",
"added_dirs": ["/a", "/b"],
"repo": { "host": "github.com", "owner": "ictechgy", "name": "understatus" }
},
"cost": { "total_cost_usd": 33.9 },
"context_window": { "context_window_size": 1000000, "used_percentage": 62 }
}"#;
let input = parse_claude_input(raw);
assert_eq!(
input.model_display_name.as_deref(),
Some("Opus 4.8 (1M context)"),
"model 보존(파싱 안 깨짐)"
);
assert_eq!(input.context_used_percentage, Some(62.0), "ctx 보존");
assert_eq!(input.cwd.as_deref(), Some("/Users/me/proj"), "cwd 보존");
assert_eq!(input.cost_usd, Some(33.9), "cost 보존");
assert_eq!(input.git_branch, None);
}
#[test]
fn model_display_name_object_drift_absorbed() {
let raw = r#"{ "model": { "display_name": { "x": 1 } }, "context_window": { "used_percentage": 50 } }"#;
let input = parse_claude_input(raw);
assert_eq!(
input.model_display_name, None,
"객체 display_name → None 흡수"
);
assert_eq!(input.context_used_percentage, Some(50.0), "ctx 보존");
}
#[test]
fn workspace_repo_string_still_used_for_git() {
let raw = r#"{ "workspace": { "repo": "/nonexistent/repo/path" }, "context_window": { "used_percentage": 30 } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.context_used_percentage, Some(30.0));
assert_eq!(input.git_branch, None, "존재 않는 경로 → None(파싱은 정상)");
}
#[test]
fn missing_fields_default_to_none() {
let raw = r#"{ "session_id": "only-session" }"#;
let input = parse_claude_input(raw);
assert_eq!(input.session_id.as_deref(), Some("only-session"));
assert_eq!(input.cwd, None);
assert_eq!(input.model_display_name, None);
assert_eq!(input.context_used_percentage, None);
assert_eq!(input.cost_usd, None);
assert_eq!(input.git_branch, None);
}
#[test]
fn empty_object_is_all_none() {
let input = parse_claude_input("{}");
assert_eq!(input, ClaudeInput::default());
}
#[test]
fn broken_json_returns_default() {
for raw in ["", " ", "not json", "{ \"model\": ", "[1,2,3]"] {
let input = parse_claude_input(raw);
assert_eq!(input, ClaudeInput::default(), "입력: {raw:?}");
}
}
#[test]
fn cwd_falls_back_to_workspace_current_dir() {
let raw = r#"{ "workspace": { "current_dir": "/ws/dir" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.cwd.as_deref(), Some("/ws/dir"));
}
#[test]
fn derives_git_branch_from_head() {
use std::io::Write;
let tmp = unique_test_dir("git-test");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let head = git_dir.join("HEAD");
let mut file = std::fs::File::create(&head).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/feature/my-branch").expect("HEAD 쓰기 실패");
let raw = format!(
r#"{{ "workspace": {{ "git_worktree": {:?} }} }}"#,
tmp.to_string_lossy()
);
let input = parse_claude_input(&raw);
assert_eq!(input.git_branch.as_deref(), Some("feature/my-branch"));
}
#[test]
fn git_worktree_derives_branch_even_when_repo_is_object() {
use std::io::Write;
let tmp = unique_test_dir("git-repoobj");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/main").expect("HEAD 쓰기 실패");
let raw = format!(
r#"{{ "workspace": {{ "git_worktree": {:?}, "repo": {{ "host": "github.com", "owner": "x", "name": "y" }} }} }}"#,
tmp.to_string_lossy()
);
let input = parse_claude_input(&raw);
assert_eq!(
input.git_branch.as_deref(),
Some("main"),
"repo 객체여도 git_worktree로 브랜치 도출"
);
}
#[test]
fn detached_head_yields_no_branch() {
let tmp = unique_test_dir("git-detached");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
std::fs::write(git_dir.join("HEAD"), "0123456789abcdef\n").expect("HEAD 쓰기 실패");
let raw = format!(
r#"{{ "workspace": {{ "git_worktree": {:?} }} }}"#,
tmp.to_string_lossy()
);
let input = parse_claude_input(&raw);
assert_eq!(input.git_branch, None);
}
#[test]
fn nonexistent_worktree_yields_no_branch() {
let raw = r#"{ "workspace": { "git_worktree": "/nonexistent/path/xyz" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.git_branch, None);
}
#[test]
fn git_worktree_with_parent_traversal_rejected() {
let raw = r#"{ "workspace": { "git_worktree": "/some/repo/../../etc" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.git_branch, None);
}
#[test]
fn absolute_system_path_yields_no_branch() {
let raw = r#"{ "workspace": { "git_worktree": "/etc" } }"#;
let input = parse_claude_input(raw);
assert_eq!(input.git_branch, None);
}
#[test]
fn derive_from_cwd_ok_branch() {
use std::io::Write;
let tmp = unique_test_dir("lterm-git-ok");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/feature/x").expect("HEAD 쓰기 실패");
let branch = derive_git_branch_from_cwd(&tmp.to_string_lossy());
assert_eq!(branch.as_deref(), Some("feature/x"));
}
#[test]
fn derive_from_cwd_detached_head_none() {
let tmp = unique_test_dir("lterm-git-detached");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
std::fs::write(git_dir.join("HEAD"), "0123456789abcdef\n").expect("HEAD 쓰기 실패");
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
}
#[test]
fn derive_from_cwd_no_git_dir_none() {
let tmp = unique_test_dir("lterm-git-nogit");
std::fs::create_dir_all(&*tmp).expect("임시 디렉터리 생성 실패");
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
}
#[test]
fn derive_from_cwd_traversal_rejected() {
assert_eq!(derive_git_branch_from_cwd("/some/repo/../../etc"), None);
}
#[test]
#[cfg(unix)]
fn derive_from_cwd_symlink_head_pointing_outside_none() {
use std::os::unix::fs::symlink;
let tmp = unique_test_dir("lterm-git-symlink");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let outside = tmp.join("outside-ref");
std::fs::write(&outside, "ref: refs/heads/leaked\n").expect("외부 ref 쓰기 실패");
symlink(&outside, git_dir.join("HEAD")).expect("심볼릭 HEAD 생성 실패");
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
}
#[test]
#[cfg(unix)]
fn derive_from_cwd_symlink_git_dir_follows_to_some() {
use std::os::unix::fs::symlink;
let real = unique_test_dir("lterm-git-symlink-real");
let real_git = real.join(".git");
std::fs::create_dir_all(&real_git).expect("실제 .git 생성 실패");
std::fs::write(real_git.join("HEAD"), "ref: refs/heads/feature/linked\n")
.expect("HEAD 쓰기 실패");
let cwd = unique_test_dir("lterm-git-symlink-cwd");
std::fs::create_dir_all(&*cwd).expect("cwd 생성 실패");
symlink(&real_git, cwd.join(".git")).expect("심볼릭 .git 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()).as_deref(),
Some("feature/linked"),
"심볼릭 .git 추종은 표준 git 동작 — 정상 ref면 branch 도출"
);
}
#[test]
fn derive_from_cwd_rejects_control_chars() {
let tmp = unique_test_dir("lterm-git-ctrl");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\x1b[31mX\n")
.expect("HEAD 쓰기 실패");
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\nINJECT\n")
.expect("HEAD 쓰기 실패");
assert_eq!(derive_git_branch_from_cwd(&tmp.to_string_lossy()), None);
}
#[test]
fn derive_from_cwd_rejects_overlong_branch() {
let tmp = unique_test_dir("lterm-git-overlong");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let overlong = "a".repeat(MAX_BRANCH_LEN + 1);
std::fs::write(
git_dir.join("HEAD"),
format!("ref: refs/heads/{overlong}\n"),
)
.expect("HEAD 쓰기 실패");
assert_eq!(
derive_git_branch_from_cwd(&tmp.to_string_lossy()),
None,
"256B 초과 branch명은 거부(FP<FN)"
);
let boundary = "b".repeat(MAX_BRANCH_LEN);
std::fs::write(
git_dir.join("HEAD"),
format!("ref: refs/heads/{boundary}\n"),
)
.expect("HEAD 쓰기 실패");
assert_eq!(
derive_git_branch_from_cwd(&tmp.to_string_lossy()).as_deref(),
Some(boundary.as_str()),
"정확히 256B branch명은 허용(경계)"
);
}
#[test]
fn derive_from_cwd_rejects_relative_cwd() {
assert_eq!(derive_git_branch_from_cwd("repo"), None);
assert_eq!(derive_git_branch_from_cwd("./x"), None);
}
#[test]
fn git_worktree_relative_path_rejected() {
let ws_rel = RawWorkspace {
git_worktree: Some("repo".to_string()),
repo: None,
current_dir: None,
project_dir: None,
};
assert_eq!(
derive_git_branch(&ws_rel),
None,
"상대 git_worktree는 절대성 가드로 거부"
);
let ws_dot = RawWorkspace {
git_worktree: Some(".".to_string()),
repo: None,
current_dir: None,
project_dir: None,
};
assert_eq!(
derive_git_branch(&ws_dot),
None,
"'.' 상대 git_worktree 거부"
);
let ws_repo = RawWorkspace {
git_worktree: None,
repo: Some("repo".to_string()),
current_dir: None,
project_dir: None,
};
assert_eq!(derive_git_branch(&ws_repo), None, "상대 repo도 거부");
}
#[test]
fn lterm_parses_normal_input() {
let cwd = unique_test_dir("lterm-normal");
let cwd_str = cwd.to_string_lossy().into_owned();
let raw = format!(
r#"{{
"source": "lterm",
"version": 1,
"session": "codex",
"pane": "%3",
"session_key": "codex/%3",
"agent": "codex",
"cwd": {cwd_str:?},
"cols": 120,
"rows": 40
}}"#
);
let input = parse_lterm_input(&raw);
assert_eq!(input.cwd.as_deref(), Some(cwd_str.as_str()));
assert_eq!(input.model_display_name.as_deref(), Some("codex"));
assert_eq!(input.session_id.as_deref(), Some("codex/%3"));
assert_eq!(input.git_branch, None);
assert_eq!(input.context_used_percentage, None);
assert_eq!(input.cost_usd, None);
}
#[test]
fn lterm_empty_object_is_all_none() {
let input = parse_lterm_input("{}");
assert_eq!(input, ClaudeInput::default());
}
#[test]
fn lterm_unknown_fields_ignored() {
let cwd = unique_test_dir("lterm-unknown");
let cwd_str = cwd.to_string_lossy().into_owned();
let raw = format!(
r#"{{
"source": "lterm",
"session": "s",
"pane": "%1",
"cwd": {cwd_str:?},
"future_field": {{ "nested": [1, 2, 3] }},
"another": "ignored"
}}"#
);
let input = parse_lterm_input(&raw);
assert_eq!(input.cwd.as_deref(), Some(cwd_str.as_str()));
assert_eq!(input.session_id.as_deref(), Some("s/%1"));
assert_eq!(input.git_branch, None);
}
#[test]
fn lterm_missing_fields_default_to_none() {
let cwd = unique_test_dir("lterm-missing");
let cwd_str = cwd.to_string_lossy().into_owned();
let raw = format!(r#"{{ "source": "lterm", "cwd": {cwd_str:?} }}"#);
let input = parse_lterm_input(&raw);
assert_eq!(input.cwd.as_deref(), Some(cwd_str.as_str()));
assert_eq!(input.model_display_name, None);
assert_eq!(input.session_id, None);
assert_eq!(input.git_branch, None);
assert_eq!(input.context_used_percentage, None);
assert_eq!(input.cost_usd, None);
}
#[test]
fn lterm_synthesizes_session_key_from_session_and_pane() {
let raw = r#"{ "session": "codex", "pane": "%7" }"#;
let input = parse_lterm_input(raw);
assert_eq!(input.session_id.as_deref(), Some("codex/%7"));
}
#[test]
fn lterm_explicit_session_key_takes_precedence() {
let raw = r#"{ "session": "codex", "pane": "%7", "session_key": "stable-key" }"#;
let input = parse_lterm_input(raw);
assert_eq!(input.session_id.as_deref(), Some("stable-key"));
}
#[test]
fn lterm_session_key_synthesis_partial() {
let only_session = parse_lterm_input(r#"{ "session": "codex" }"#);
assert_eq!(only_session.session_id.as_deref(), Some("codex"));
let only_pane = parse_lterm_input(r#"{ "pane": "%2" }"#);
assert_eq!(only_pane.session_id.as_deref(), Some("%2"));
}
#[test]
fn lterm_empty_session_key_falls_back_to_synthesis() {
let raw = r#"{ "session": "s", "pane": "%1", "session_key": "" }"#;
let input = parse_lterm_input(raw);
assert_eq!(input.session_id.as_deref(), Some("s/%1"));
}
#[test]
fn lterm_broken_json_returns_default() {
for raw in ["", " ", "not json", "{ \"session\": ", "[1,2,3]"] {
let input = parse_lterm_input(raw);
assert_eq!(input, ClaudeInput::default(), "입력: {raw:?}");
}
}
#[test]
fn lterm_version_is_ignored() {
let with_version = parse_lterm_input(r#"{ "session": "s", "pane": "%1", "version": 99 }"#);
let without_version = parse_lterm_input(r#"{ "session": "s", "pane": "%1" }"#);
assert_eq!(with_version, without_version);
}
#[test]
fn lterm_session_label_synthesized_from_session_and_pane() {
let raw = r#"{ "session": "codex", "pane": "%3", "cwd": "/x/proj" }"#;
let input = parse_lterm_input(raw);
assert_eq!(input.session_label.as_deref(), Some("codex/%3"));
}
#[test]
fn lterm_session_label_partial_and_absent() {
let only_session = parse_lterm_input(r#"{ "session": "codex" }"#);
assert_eq!(only_session.session_label.as_deref(), Some("codex"));
let only_pane = parse_lterm_input(r#"{ "pane": "%2" }"#);
assert_eq!(only_pane.session_label.as_deref(), Some("%2"));
let neither = parse_lterm_input(r#"{ "cwd": "/x" }"#);
assert_eq!(neither.session_label, None);
}
#[test]
fn lterm_session_label_independent_of_explicit_session_key() {
let raw = r#"{ "session": "codex", "pane": "%7", "session_key": "stable-key" }"#;
let input = parse_lterm_input(raw);
assert_eq!(input.session_id.as_deref(), Some("stable-key"));
assert_eq!(input.session_label.as_deref(), Some("codex/%7"));
}
#[test]
fn session_label_none_for_empty_and_claude() {
assert_eq!(parse_lterm_input("{}").session_label, None);
let claude = parse_claude_input(r#"{ "session_id": "s", "cwd": "/x" }"#);
assert_eq!(claude.session_label, None);
}
#[test]
fn lterm_ignored_field_type_drift_preserves_useful_fields() {
let cwd = unique_test_dir("lterm-drift");
let cwd_str = cwd.to_string_lossy().into_owned();
let raw = format!(
r#"{{
"session": "codex",
"pane": "%3",
"agent": "codex",
"cwd": {cwd_str:?},
"version": "1",
"cols": "120",
"rows": "40"
}}"#
);
let input = parse_lterm_input(&raw);
assert_eq!(input.session_id.as_deref(), Some("codex/%3"));
assert_eq!(input.model_display_name.as_deref(), Some("codex"));
assert_eq!(input.cwd.as_deref(), Some(cwd_str.as_str()));
assert_eq!(input.git_branch, None);
}
#[test]
fn lterm_git_cwd_derives_branch() {
use std::io::Write;
let tmp = unique_test_dir("lterm-parse-git");
let git_dir = tmp.join(".git");
std::fs::create_dir_all(&git_dir).expect("임시 .git 생성 실패");
let mut file = std::fs::File::create(git_dir.join("HEAD")).expect("HEAD 생성 실패");
writeln!(file, "ref: refs/heads/main").expect("HEAD 쓰기 실패");
let raw = format!(
r#"{{ "source": "lterm", "session": "codex", "pane": "%3", "cwd": {:?} }}"#,
tmp.to_string_lossy()
);
let input = parse_lterm_input(&raw);
assert_eq!(
input.git_branch.as_deref(),
Some("main"),
"유효 git cwd → branch 도출"
);
}
#[test]
fn derive_from_cwd_gitfile_not_followed_none() {
let gitdir_abs = unique_test_dir("gitfile-target-abs");
std::fs::create_dir_all(&*gitdir_abs).expect("gitdir 생성 실패");
std::fs::write(gitdir_abs.join("HEAD"), "ref: refs/heads/sentinel-abs\n")
.expect("타깃 HEAD 생성 실패");
let base_abs = unique_test_dir("gitfile-abs");
std::fs::create_dir_all(&*base_abs).expect("cwd 생성 실패");
std::fs::write(
base_abs.join(".git"),
format!("gitdir: {}\n", gitdir_abs.to_string_lossy()),
)
.expect(".git 파일 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&base_abs.to_string_lossy()),
None,
"절대 gitdir gitfile 미추종 → None(추종 구현 시 Some(sentinel-abs)로 깨짐)"
);
let base_rel = unique_test_dir("gitfile-rel");
std::fs::create_dir_all(base_rel.join("realgit")).expect("상대 gitdir 생성 실패");
std::fs::write(
base_rel.join("realgit").join("HEAD"),
"ref: refs/heads/sentinel-rel\n",
)
.expect("타깃 HEAD 생성 실패");
std::fs::write(base_rel.join(".git"), "gitdir: realgit\n").expect(".git 파일 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&base_rel.to_string_lossy()),
None,
"상대 gitdir gitfile 미추종 → None(추종 구현 시 Some(sentinel-rel)로 깨짐)"
);
}
#[test]
fn derive_git_branch_gitfile_worktree_not_followed_none() {
let gitdir = unique_test_dir("gitfile-ws-target");
std::fs::create_dir_all(&*gitdir).expect("gitdir 생성 실패");
std::fs::write(gitdir.join("HEAD"), "ref: refs/heads/sentinel-ws\n")
.expect("타깃 HEAD 생성 실패");
let base = unique_test_dir("gitfile-ws");
std::fs::create_dir_all(&*base).expect("워크트리 생성 실패");
std::fs::write(
base.join(".git"),
format!("gitdir: {}\n", gitdir.to_string_lossy()),
)
.expect(".git 파일 생성 실패");
let ws = RawWorkspace {
git_worktree: Some(base.to_string_lossy().into_owned()),
repo: None,
current_dir: None,
project_dir: None,
};
assert_eq!(
derive_git_branch(&ws),
None,
"gitfile 워크트리 미추종 → None(추종 구현 시 Some(sentinel-ws)로 깨짐)"
);
}
#[test]
fn derive_from_cwd_walk_up_from_subdir_some() {
let root = unique_test_dir("walkup-subdir");
std::fs::create_dir_all(root.join(".git")).expect("root .git 생성 실패");
std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n")
.expect("HEAD 쓰기 실패");
let deep = root.join("src").join("deep");
std::fs::create_dir_all(&deep).expect("하위 dir 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&deep.to_string_lossy()).as_deref(),
Some("main"),
"repo 하위 dir에서 부모 walk-up으로 루트 branch 도출(P1)"
);
}
#[test]
fn derive_from_cwd_walk_up_stops_at_first_git_dir() {
let root = unique_test_dir("walkup-firststop");
std::fs::create_dir_all(root.join(".git")).expect("outer .git 생성 실패");
std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/outer\n")
.expect("outer HEAD 쓰기 실패");
let inner = root.join("inner");
std::fs::create_dir_all(inner.join(".git")).expect("inner .git 생성 실패");
std::fs::write(inner.join(".git").join("HEAD"), "ref: refs/heads/inner\n")
.expect("inner HEAD 쓰기 실패");
let cwd = inner.join("x");
std::fs::create_dir_all(&cwd).expect("cwd 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()).as_deref(),
Some("inner"),
"가장 가까운 첫 .git에서 정지 — 부모 outer로 올라가지 않음"
);
}
#[test]
fn derive_from_cwd_walk_stops_at_gitfile_boundary_none() {
let main = unique_test_dir("walkup-gitfile-main");
std::fs::create_dir_all(main.join(".git")).expect("main .git 생성 실패");
std::fs::write(
main.join(".git").join("HEAD"),
"ref: refs/heads/outer-main\n",
)
.expect("main HEAD 쓰기 실패");
let gitdir = main.join(".git").join("worktrees").join("wt");
std::fs::create_dir_all(&gitdir).expect("gitdir 생성 실패");
std::fs::write(gitdir.join("HEAD"), "ref: refs/heads/sentinel-wt\n")
.expect("gitdir HEAD 쓰기 실패");
let wt = main.join("wt");
std::fs::create_dir_all(wt.join("src")).expect("wt/src 생성 실패");
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", gitdir.to_string_lossy()),
)
.expect(".git gitfile 생성 실패");
let cwd = wt.join("src");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()),
None,
"gitfile 경계에서 정지+미추종 → None(추종 추가 시 Some(sentinel-wt), 경계 무시 상승 시 Some(outer-main)로 깨짐)"
);
}
#[test]
fn derive_from_cwd_walk_stops_at_empty_git_dir() {
let root = unique_test_dir("walkup-emptygit");
std::fs::create_dir_all(root.join(".git")).expect("root .git 생성 실패");
std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/real\n")
.expect("root HEAD 쓰기 실패");
let sub = root.join("sub");
std::fs::create_dir_all(sub.join(".git")).expect("sub .git 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&sub.to_string_lossy()),
None,
"경계 .git이 HEAD 부재여도 정지 — 부모 real로 상승하지 않음"
);
}
#[test]
#[cfg(unix)]
fn derive_from_cwd_walk_up_symlink_cwd_no_cross_repo() {
use std::os::unix::fs::symlink;
let base = unique_test_dir("walkup-symlink");
let repo = base.join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("repo/.git 생성 실패");
std::fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/leaked\n")
.expect("repo HEAD 쓰기 실패");
std::fs::create_dir_all(base.join("target").join("sub")).expect("target/sub 생성 실패");
let link = repo.join("link");
symlink(base.join("target"), &link).expect("심볼릭 link 생성 실패");
let cwd = link.join("sub");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()),
None,
"canonical 실경로(target, .git 없음) 조상만 순회 → None. \
canonicalize 제거 시 lexical repo/.git을 만나 Some(\"leaked\")로 깨짐(threat#1)"
);
}
#[test]
fn derive_from_cwd_walk_up_parent_repo_positive() {
let repo = unique_test_dir("walkup-parent-positive");
std::fs::create_dir_all(repo.join(".git")).expect("repo .git 생성 실패");
std::fs::write(
repo.join(".git").join("HEAD"),
"ref: refs/heads/parent-main\n",
)
.expect("HEAD 쓰기 실패");
let cwd = repo.join("untracked").join("sub");
std::fs::create_dir_all(&cwd).expect("untracked/sub 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()).as_deref(),
Some("parent-main"),
"추적 안 되는 하위 dir도 부모 work tree 안이면 branch 표시 = git-consistent 정탐(FP 아님)"
);
}
#[test]
fn derive_from_cwd_walk_up_root_repo_unchanged() {
let root = unique_test_dir("walkup-root-unchanged");
std::fs::create_dir_all(root.join(".git")).expect("root .git 생성 실패");
std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n")
.expect("HEAD 쓰기 실패");
assert_eq!(
derive_git_branch_from_cwd(&root.to_string_lossy()).as_deref(),
Some("main"),
"depth 0(cwd 자신이 repo 루트) 동작은 기존 cwd-only와 동일"
);
}
#[test]
#[cfg(unix)]
fn derive_from_cwd_walk_up_symlink_git_dir_at_parent_some() {
use std::os::unix::fs::symlink;
let base = unique_test_dir("walkup-symlink-gitdir");
std::fs::create_dir_all(base.join(".git")).expect("base .git 생성 실패");
std::fs::write(
base.join(".git").join("HEAD"),
"ref: refs/heads/outer-main\n",
)
.expect("base HEAD 쓰기 실패");
let realgit = base.join("realgit").join(".git");
std::fs::create_dir_all(&realgit).expect("realgit/.git 생성 실패");
std::fs::write(realgit.join("HEAD"), "ref: refs/heads/inner-real\n")
.expect("realgit HEAD 쓰기 실패");
let inner = base.join("inner");
std::fs::create_dir_all(inner.join("sub")).expect("inner/sub 생성 실패");
symlink(&realgit, inner.join(".git")).expect("심볼릭 inner/.git 생성 실패");
let cwd = inner.join("sub");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()).as_deref(),
Some("inner-real"),
"심볼릭 .git 엔트리에서 정지·추종 → inner-real. is_dir()/is_file() 분기 시 skip돼 부모 outer-main 누출로 깨짐"
);
}
#[test]
#[cfg(unix)]
fn derive_from_cwd_walk_up_dangling_git_symlink_stops_none() {
use std::os::unix::fs::symlink;
let base = unique_test_dir("walkup-dangling-gitsymlink");
std::fs::create_dir_all(base.join(".git")).expect("base .git 생성 실패");
std::fs::write(
base.join(".git").join("HEAD"),
"ref: refs/heads/outer-main\n",
)
.expect("base HEAD 쓰기 실패");
let inner = base.join("inner");
std::fs::create_dir_all(inner.join("sub")).expect("inner/sub 생성 실패");
symlink(base.join("nonexistent"), inner.join(".git")).expect("dangling 심볼릭 생성 실패");
let cwd = inner.join("sub");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()),
None,
"dangling .git 심볼릭에서 정지(lstat가 심볼릭 존재 인식)+reader canonicalize Err→None. 부모 outer-main 상승 시 Some(\"outer-main\")으로 깨짐"
);
}
#[test]
fn derive_from_cwd_walk_up_depth_cap_none() {
let root = unique_test_dir("walkup-depthcap");
std::fs::create_dir_all(root.join(".git")).expect("root .git 생성 실패");
std::fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main\n")
.expect("HEAD 쓰기 실패");
let deep = root.join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep).expect("깊은 cwd 생성 실패");
let start = std::fs::canonicalize(&deep).expect("canonicalize 실패");
assert_eq!(
find_git_root_dir_capped(&start, 3),
None,
"cap 초과 깊이의 .git은 미발견 → None(threat#3 mount loop·병적 깊이 차단)"
);
let canonical_root = std::fs::canonicalize(&*root).expect("root canonicalize 실패");
assert_eq!(
find_git_root_dir_capped(&start, MAX_WALK_UP_DEPTH),
Some(canonical_root),
"넉넉한 cap이면 root .git 발견(cap만이 차이를 만드는 변수)"
);
}
#[test]
fn derive_from_cwd_walk_up_prod_depth_cap_none() {
let root = unique_test_dir("walkup-prod-depthcap");
std::fs::create_dir_all(root.join(".git")).expect("root .git 생성 실패");
std::fs::write(
root.join(".git").join("HEAD"),
"ref: refs/heads/deep-root\n",
)
.expect("root HEAD 쓰기 실패");
let mut deep = root.to_path_buf();
for i in 1..=64 {
deep = deep.join(format!("c{i}"));
}
std::fs::create_dir_all(&deep).expect("64단계 중첩 dir 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&deep.to_string_lossy()),
None,
"root .git이 ancestors index 64(MAX_WALK_UP_DEPTH 방문 범위 0..63 밖)라 prod cap이 차단 → None"
);
}
#[test]
fn derive_from_cwd_walk_up_prod_depth_in_cap_some() {
let root = unique_test_dir("walkup-prod-depth-incap");
std::fs::create_dir_all(root.join(".git")).expect("root .git 생성 실패");
std::fs::write(
root.join(".git").join("HEAD"),
"ref: refs/heads/in-cap-root\n",
)
.expect("root HEAD 쓰기 실패");
let mut deep = root.to_path_buf();
for i in 1..=63 {
deep = deep.join(format!("c{i}"));
}
std::fs::create_dir_all(&deep).expect("63단계 중첩 dir 생성 실패");
assert_eq!(
derive_git_branch_from_cwd(&deep.to_string_lossy()).as_deref(),
Some("in-cap-root"),
"root .git이 ancestors index 63(MAX_WALK_UP_DEPTH 방문 범위 0..63 내부)라 prod cap이 발견 → Some. cap을 64 미만으로 낮추는 mutation 시 이 in-cap repo가 None으로 깨짐"
);
}
#[test]
#[cfg(unix)]
fn derive_from_cwd_walk_up_symlink_git_to_external_repo_follows_some() {
use std::os::unix::fs::symlink;
let external = unique_test_dir("walkup-symlink-external-repo");
let external_git = external.join(".git");
std::fs::create_dir_all(&external_git).expect("external/.git 생성 실패");
std::fs::write(
external_git.join("HEAD"),
"ref: refs/heads/external-branch\n",
)
.expect("external HEAD 쓰기 실패");
let base = unique_test_dir("walkup-symlink-external-base");
std::fs::create_dir_all(base.join("sub")).expect("base/sub 생성 실패");
symlink(&external_git, base.join(".git")).expect("심볼릭 base/.git 생성 실패");
let cwd = base.join("sub");
assert_eq!(
derive_git_branch_from_cwd(&cwd.to_string_lossy()).as_deref(),
Some("external-branch"),
"심볼릭 .git이 walk-up 트리 밖 외부 repo를 가리켜도 추종 → external-branch. 표준 git 동작(git-consistent)이라 FP 아닌 정탐"
);
}
#[test]
fn compute_remaining_secs_defends_corrupt_inputs() {
let now_ms: u128 = 1_000_000_000_000; let now_secs = 1_000_000_000_i64;
assert_eq!(
compute_remaining_secs((now_secs + 9000) as f64, now_ms, RATE_5H_MAX_REMAINING_SECS),
Some(9000)
);
assert_eq!(
compute_remaining_secs(
(now_secs + 360_000) as f64,
now_ms,
RATE_WEEKLY_MAX_REMAINING_SECS
),
Some(360_000)
);
assert_eq!(
compute_remaining_secs(
(now_secs + 20_000) as f64,
now_ms,
RATE_5H_MAX_REMAINING_SECS
),
None
);
assert_eq!(
compute_remaining_secs(
(now_secs + 700_000) as f64,
now_ms,
RATE_WEEKLY_MAX_REMAINING_SECS
),
None
);
assert_eq!(
compute_remaining_secs((now_secs - 100) as f64, now_ms, RATE_5H_MAX_REMAINING_SECS),
None
);
assert_eq!(
compute_remaining_secs(f64::NAN, now_ms, RATE_5H_MAX_REMAINING_SECS),
None
);
assert_eq!(
compute_remaining_secs(f64::INFINITY, now_ms, RATE_5H_MAX_REMAINING_SECS),
None
);
assert_eq!(
compute_remaining_secs(-1e300, now_ms, RATE_5H_MAX_REMAINING_SECS),
None
);
assert_eq!(
compute_remaining_secs((now_secs + 9000) as f64, 0, RATE_5H_MAX_REMAINING_SECS),
None
);
assert_eq!(
compute_remaining_secs(
(now_secs + RATE_5H_MAX_REMAINING_SECS) as f64,
now_ms,
RATE_5H_MAX_REMAINING_SECS
),
Some(RATE_5H_MAX_REMAINING_SECS)
);
assert_eq!(
compute_remaining_secs(
(now_secs + RATE_5H_MAX_REMAINING_SECS + 1) as f64,
now_ms,
RATE_5H_MAX_REMAINING_SECS
),
None
);
}
#[test]
fn format_reset_countdown_boundaries() {
assert_eq!(format_reset_countdown(9000).as_deref(), Some("2h30m"));
assert_eq!(format_reset_countdown(0), None);
assert_eq!(format_reset_countdown(-5), None);
assert_eq!(format_reset_countdown(360_000).as_deref(), Some("4d4h"));
assert_eq!(format_reset_countdown(30).as_deref(), Some("<1m"));
assert_eq!(format_reset_countdown(60).as_deref(), Some("1m"));
assert_eq!(format_reset_countdown(86_400).as_deref(), Some("1d0h"));
assert_eq!(format_reset_countdown(3600).as_deref(), Some("1h0m"));
assert_eq!(format_reset_countdown(86_399).as_deref(), Some("23h59m"));
}
#[test]
fn clamp_rate_percent_guards() {
assert_eq!(clamp_rate_percent(f64::NAN), 0);
assert_eq!(clamp_rate_percent(f64::INFINITY), 0);
assert_eq!(clamp_rate_percent(-3.0), 0);
assert_eq!(clamp_rate_percent(0.0), 0);
assert_eq!(clamp_rate_percent(23.4), 23);
assert_eq!(clamp_rate_percent(23.6), 24);
assert_eq!(clamp_rate_percent(100.0), 100);
assert_eq!(clamp_rate_percent(151.0), 100);
assert_eq!(clamp_rate_percent(99.9), 100);
}
#[test]
fn parse_rate_limits_full_present() {
let raw = r#"{"rate_limits":{"five_hour":{"used_percentage":23.5,"resets_at":1700000000},"seven_day":{"used_percentage":41.2,"resets_at":1700500000}}}"#;
let input = parse_claude_input(raw);
assert_eq!(input.rate_5h_percent, Some(23.5));
assert_eq!(input.internal_rate_5h_resets_at_raw, Some(1_700_000_000.0));
assert_eq!(input.rate_weekly_percent, Some(41.2));
assert_eq!(
input.internal_rate_weekly_resets_at_raw,
Some(1_700_500_000.0)
);
assert_eq!(input.rate_5h_countdown, None);
assert_eq!(input.rate_weekly_countdown, None);
}
#[test]
fn parse_rate_limits_absent_null_partial() {
let none = parse_claude_input(r#"{"model":{"display_name":"Opus"}}"#);
assert_eq!(none.rate_5h_percent, None);
assert_eq!(none.rate_weekly_percent, None);
let null = parse_claude_input(r#"{"rate_limits":null}"#);
assert_eq!(null.rate_5h_percent, None);
assert_eq!(null.internal_rate_weekly_resets_at_raw, None);
let only5 = parse_claude_input(
r#"{"rate_limits":{"five_hour":{"used_percentage":10,"resets_at":1}}}"#,
);
assert_eq!(only5.rate_5h_percent, Some(10.0));
assert_eq!(only5.rate_weekly_percent, None);
let only7 = parse_claude_input(
r#"{"rate_limits":{"seven_day":{"used_percentage":70,"resets_at":2}}}"#,
);
assert_eq!(only7.rate_weekly_percent, Some(70.0));
assert_eq!(only7.rate_5h_percent, None);
}
#[test]
fn parse_rate_limits_type_drift_isolated() {
let drift = parse_claude_input(
r#"{"model":{"display_name":"Opus"},"rate_limits":{"five_hour":{"used_percentage":"bad","resets_at":"x"}}}"#,
);
assert_eq!(drift.model_display_name.as_deref(), Some("Opus"));
assert_eq!(drift.rate_5h_percent, None);
assert_eq!(drift.internal_rate_5h_resets_at_raw, None);
let nonobj = parse_claude_input(
r#"{"model":{"display_name":"Opus"},"rate_limits":{"five_hour":5,"seven_day":{"used_percentage":40,"resets_at":3}}}"#,
);
assert_eq!(nonobj.model_display_name.as_deref(), Some("Opus"));
assert_eq!(nonobj.rate_5h_percent, None);
assert_eq!(nonobj.rate_weekly_percent, Some(40.0));
}
#[test]
fn parse_lterm_rate_limits_all_none() {
let input = parse_lterm_input(r#"{"source":"lterm","agent":"codex","cwd":"/tmp/x"}"#);
assert_eq!(input.rate_5h_percent, None);
assert_eq!(input.rate_5h_countdown, None);
assert_eq!(input.rate_weekly_percent, None);
assert_eq!(input.rate_weekly_countdown, None);
assert_eq!(input.internal_rate_5h_resets_at_raw, None);
assert_eq!(input.internal_rate_weekly_resets_at_raw, None);
}
}