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 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),
};
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,
}
}
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)
}
const CONTEXT_HOLD_DROP_TOLERANCE: f64 = 12.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>,
) -> 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 - CONTEXT_HOLD_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());
ClaudeInput {
model_display_name: raw_input.agent,
context_used_percentage: None,
context_fallback_percentage: None,
cwd: raw_input.cwd,
git_branch: None,
cost_usd: None,
session_id: session_key,
session_label,
codex: 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;
}
read_branch_from_git_dir(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))
}
fn read_branch_from_git_dir(base_path: &str) -> Option<String> {
use std::path::Path;
let head_path = Path::new(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() {
None
} else {
Some(branch.to_string())
}
}
#[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>,
}
#[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::*;
#[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);
}
#[test]
fn resolve_prefers_positive_native_and_persists() {
let r = resolve_context_percent(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_context_percent(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_context_percent(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_context_percent(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_context_percent(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_context_percent(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_context_percent(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_context_percent(Some(f64::NAN), None, None);
assert_eq!(cold.display, None, "NaN은 raw native 표시 후보에서도 제외");
}
#[test]
fn resolve_breaks_hold_on_real_drop() {
let r = resolve_context_percent(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_context_percent(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_context_percent(None, Some(74.0), Some(86.0)).display,
Some(74.0)
);
assert_eq!(
resolve_context_percent(None, Some(75.0), Some(86.0)).display,
Some(86.0)
);
}
#[test]
fn resolve_holds_when_no_fallback_to_compare() {
let r = resolve_context_percent(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_context_percent(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_context_percent(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_context_percent(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_context_percent(None, Some(45.0), Some(-5.0)).display,
Some(45.0)
);
assert_eq!(resolve_context_percent(None, None, Some(0.0)).display, None);
assert_eq!(
resolve_context_percent(None, Some(30.0), Some(f64::NAN)).display,
Some(30.0)
);
}
#[test]
fn resolve_rejects_nonfinite_fallback() {
assert_eq!(
resolve_context_percent(None, Some(f64::NAN), None).display,
None
);
assert_eq!(
resolve_context_percent(None, Some(f64::INFINITY), None).display,
None
);
}
#[test]
fn resolve_clamps_out_of_range_fallback() {
let r = resolve_context_percent(None, Some(150.0), None);
assert_eq!(r.display, Some(100.0));
}
#[test]
fn resolve_normalized_fallback_does_not_break_hold() {
let r = resolve_context_percent(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_context_percent(None, Some(f64::NAN), Some(86.0)).display,
Some(86.0)
);
}
#[test]
fn resolve_drop_guard_uses_clamped_held() {
assert_eq!(
resolve_context_percent(None, Some(89.0), Some(150.0)).display,
Some(100.0),
"89 > 88 → 클램프된 held(100) 유지",
);
assert_eq!(
resolve_context_percent(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 = std::env::temp_dir().join(format!("understatus-git-test-{}", std::process::id()));
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"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn git_worktree_derives_branch_even_when_repo_is_object() {
use std::io::Write;
let tmp =
std::env::temp_dir().join(format!("understatus-git-repoobj-{}", std::process::id()));
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로 브랜치 도출"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn detached_head_yields_no_branch() {
let tmp =
std::env::temp_dir().join(format!("understatus-git-detached-{}", std::process::id()));
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);
let _ = std::fs::remove_dir_all(&tmp);
}
#[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 lterm_parses_normal_input() {
let raw = r#"{
"source": "lterm",
"version": 1,
"session": "codex",
"pane": "%3",
"session_key": "codex/%3",
"agent": "codex",
"cwd": "/Users/me/dev/app",
"cols": 120,
"rows": 40
}"#;
let input = parse_lterm_input(raw);
assert_eq!(input.cwd.as_deref(), Some("/Users/me/dev/app"));
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 raw = r#"{
"source": "lterm",
"session": "s",
"pane": "%1",
"cwd": "/tmp/x",
"future_field": { "nested": [1, 2, 3] },
"another": "ignored"
}"#;
let input = parse_lterm_input(raw);
assert_eq!(input.cwd.as_deref(), Some("/tmp/x"));
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 raw = r#"{ "source": "lterm", "cwd": "/only/cwd" }"#;
let input = parse_lterm_input(raw);
assert_eq!(input.cwd.as_deref(), Some("/only/cwd"));
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 raw = r#"{
"session": "codex",
"pane": "%3",
"agent": "codex",
"cwd": "/Users/me/dev/app",
"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("/Users/me/dev/app"));
assert_eq!(input.git_branch, None);
}
}