use crate::chain::{
cache_now_millis, is_named_cache_fresh, read_session_named_cache_in,
write_session_named_cache_in,
};
use crate::claude::ClaudeInput;
use crate::config::Config;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const HEAD_READ_BYTES: u64 = 16 * 1024;
const FIRST_LINE_READ_BYTES: u64 = 128 * 1024;
const TAIL_READ_BYTES: u64 = 256 * 1024;
const CODEX_CACHE_FILE: &str = "codex_session";
const INTERACTIVE_ORIGINATOR_PREFIX: &str = "codex-tui";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CodexExtras {
pub rate_5h_percent: Option<f64>,
pub rate_weekly_percent: Option<f64>,
pub plan: Option<String>,
pub effort: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CodexSession {
pub model: Option<String>,
pub context_percentage: Option<f64>,
pub extras: CodexExtras,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Resolution {
Single(CodexSession, PathBuf),
Ambiguous,
None,
}
#[derive(Debug, Clone, PartialEq)]
struct SessionMeta {
cwd: Option<String>,
originator: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
struct TokenSnapshot {
last_total_tokens: Option<u64>,
context_window: Option<u64>,
rate_5h_percent: Option<f64>,
rate_weekly_percent: Option<f64>,
plan: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
struct TurnContext {
model: Option<String>,
effort: Option<String>,
}
fn parse_session_meta(first_line: &str) -> Option<SessionMeta> {
let value: serde_json::Value = serde_json::from_str(first_line.trim()).ok()?;
if value.get("type").and_then(|t| t.as_str()) != Some("session_meta") {
return None;
}
let payload = value.get("payload")?;
Some(SessionMeta {
cwd: payload
.get("cwd")
.and_then(|v| v.as_str())
.map(str::to_string),
originator: payload
.get("originator")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
fn parse_turn_context(line: &str) -> Option<TurnContext> {
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
if value.get("type").and_then(|t| t.as_str()) != Some("turn_context") {
return None;
}
let payload = value.get("payload")?;
Some(TurnContext {
model: payload
.get("model")
.and_then(|v| v.as_str())
.map(str::to_string),
effort: payload
.get("effort")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
fn parse_token_count(line: &str) -> Option<TokenSnapshot> {
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
if value.get("type").and_then(|t| t.as_str()) != Some("event_msg") {
return None;
}
let payload = value.get("payload")?;
if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") {
return None;
}
let info = payload.get("info");
let last_total_tokens = info
.and_then(|i| i.get("last_token_usage"))
.and_then(|u| u.get("total_tokens"))
.and_then(serde_json::Value::as_u64);
let context_window = info
.and_then(|i| i.get("model_context_window"))
.and_then(serde_json::Value::as_u64);
let rate_limits = payload.get("rate_limits");
let (rate_5h_percent, rate_weekly_percent) = extract_rate_windows(rate_limits);
let plan = rate_limits
.and_then(|r| r.get("plan_type"))
.and_then(|v| v.as_str())
.map(str::to_string);
Some(TokenSnapshot {
last_total_tokens,
context_window,
rate_5h_percent,
rate_weekly_percent,
plan,
})
}
fn extract_rate_windows(rate_limits: Option<&serde_json::Value>) -> (Option<f64>, Option<f64>) {
let mut rate_5h = None;
let mut rate_weekly = None;
let Some(rate_limits) = rate_limits else {
return (None, None);
};
for field in ["primary", "secondary"] {
let Some(window) = rate_limits.get(field) else {
continue;
};
let window_minutes = window
.get("window_minutes")
.and_then(serde_json::Value::as_u64);
let used_percent = window
.get("used_percent")
.and_then(serde_json::Value::as_f64);
match window_minutes {
Some(300) => rate_5h = used_percent,
Some(10080) => rate_weekly = used_percent,
_ => {}
}
}
(rate_5h, rate_weekly)
}
fn compute_context_percentage(total: u64, window: u64) -> Option<f64> {
if window == 0 {
return None;
}
Some((total as f64 / window as f64) * 100.0)
}
fn is_interactive_originator(originator: Option<&str>) -> bool {
originator
.map(|o| o.starts_with(INTERACTIVE_ORIGINATOR_PREFIX))
.unwrap_or(false)
}
fn cwd_matches(candidate_cwd: &str, target_cwd: &str) -> bool {
let normalize = |p: &str| -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p.trim_end_matches('/')))
};
normalize(candidate_cwd) == normalize(target_cwd)
}
fn read_head_tail(path: &Path) -> Option<(String, String)> {
let mut file = File::open(path).ok()?;
let file_len = file.metadata().ok()?.len();
let head_len = file_len.min(HEAD_READ_BYTES);
let mut head_buf = vec![0u8; head_len as usize];
file.seek(SeekFrom::Start(0)).ok()?;
read_exact_lossy(&mut file, &mut head_buf)?;
let mut head_text = String::from_utf8_lossy(&head_buf).into_owned();
if file_len > head_len {
if let Some(idx) = head_text.rfind('\n') {
head_text.truncate(idx);
}
}
let tail_len = file_len.min(TAIL_READ_BYTES);
let tail_start = file_len - tail_len;
let mut tail_buf = vec![0u8; tail_len as usize];
file.seek(SeekFrom::Start(tail_start)).ok()?;
read_exact_lossy(&mut file, &mut tail_buf)?;
let mut tail_text = String::from_utf8_lossy(&tail_buf).into_owned();
if tail_start > 0 {
if let Some(idx) = tail_text.find('\n') {
tail_text = tail_text[idx + 1..].to_string();
}
}
Some((head_text, tail_text))
}
fn read_exact_lossy(file: &mut File, buf: &mut Vec<u8>) -> Option<()> {
let mut filled = 0usize;
while filled < buf.len() {
match file.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => return None,
}
}
buf.truncate(filled);
Some(())
}
fn extract_from_file(path: &Path) -> Option<CodexSession> {
let (head_text, tail_text) = read_head_tail(path)?;
let mut turn = head_text
.lines()
.find_map(parse_turn_context)
.unwrap_or_default();
if let Some(latest_turn) = tail_text.lines().rev().find_map(parse_turn_context) {
if latest_turn.model.is_some() {
turn.model = latest_turn.model;
}
if latest_turn.effort.is_some() {
turn.effort = latest_turn.effort;
}
}
let snapshot = tail_text
.lines()
.rev()
.find_map(parse_token_count)
.unwrap_or_default();
let context_percentage = match (snapshot.last_total_tokens, snapshot.context_window) {
(Some(total), Some(window)) => compute_context_percentage(total, window),
_ => None,
};
Some(CodexSession {
model: turn.model,
context_percentage,
extras: CodexExtras {
rate_5h_percent: snapshot.rate_5h_percent,
rate_weekly_percent: snapshot.rate_weekly_percent,
plan: snapshot.plan,
effort: turn.effort,
},
})
}
fn find_codex_candidates(
base: &Path,
cwd: &str,
now: SystemTime,
freshness: u64,
scan_days: usize,
) -> Vec<PathBuf> {
let sessions_dir = base.join("sessions");
let day_dirs = recent_day_dirs(&sessions_dir, scan_days);
let freshness_secs = freshness.saturating_mul(60);
let mut candidates = Vec::new();
for day_dir in day_dirs {
let entries = match std::fs::read_dir(&day_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if !is_rollout_file(&path) {
continue;
}
if !is_fresh(&path, now, freshness_secs) {
continue;
}
if let Some(meta) = read_first_line_meta(&path) {
let cwd_ok = meta
.cwd
.as_deref()
.map(|c| cwd_matches(c, cwd))
.unwrap_or(false);
let originator_ok = is_interactive_originator(meta.originator.as_deref());
if cwd_ok && originator_ok {
candidates.push(path);
}
}
}
}
candidates
}
fn recent_day_dirs(sessions_dir: &Path, scan_days: usize) -> Vec<PathBuf> {
let mut result = Vec::new();
if scan_days == 0 {
return result;
}
for year_dir in sorted_subdirs_desc(sessions_dir) {
for month_dir in sorted_subdirs_desc(&year_dir) {
for day_dir in sorted_subdirs_desc(&month_dir) {
result.push(day_dir);
if result.len() >= scan_days {
return result;
}
}
}
}
result
}
fn sorted_subdirs_desc(dir: &Path) -> Vec<PathBuf> {
let mut subdirs: Vec<PathBuf> = match std::fs::read_dir(dir) {
Ok(entries) => entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect(),
Err(_) => return Vec::new(),
};
subdirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
subdirs
}
fn is_rollout_file(path: &Path) -> bool {
if !path.is_file() {
return false;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => return false,
};
name.starts_with("rollout-") && name.ends_with(".jsonl")
}
fn is_fresh(path: &Path, now: SystemTime, freshness_secs: u64) -> bool {
let modified = match path.metadata().and_then(|m| m.modified()) {
Ok(m) => m,
Err(_) => return false,
};
match now.duration_since(modified) {
Ok(elapsed) => elapsed.as_secs() <= freshness_secs,
Err(_) => true,
}
}
fn read_first_line_meta(path: &Path) -> Option<SessionMeta> {
let mut file = File::open(path).ok()?;
let mut buf = vec![0u8; FIRST_LINE_READ_BYTES as usize];
read_exact_lossy(&mut file, &mut buf)?;
let text = String::from_utf8_lossy(&buf);
let first_line = match text.split_once('\n') {
Some((line, _)) => line,
None => text.as_ref(),
};
parse_session_meta(first_line)
}
fn read_codex_session(
base: &Path,
cwd: &str,
now: SystemTime,
freshness: u64,
scan_days: usize,
) -> Resolution {
let candidates = find_codex_candidates(base, cwd, now, freshness, scan_days);
match candidates.len() {
0 => Resolution::None,
1 => {
let path = &candidates[0];
match extract_from_file(path) {
Some(session) => Resolution::Single(session, path.clone()),
None => Resolution::None,
}
}
_ => Resolution::Ambiguous,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CodexCacheEntry {
path: String,
mtime_ms: u128,
session: CodexSession,
}
fn file_mtime_ms(path: &Path) -> Option<u128> {
let modified = path.metadata().and_then(|m| m.modified()).ok()?;
modified
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_millis())
}
fn is_mtime_fresh(mtime_ms: u128, now: SystemTime, freshness_secs: u64) -> bool {
let now_ms = match now.duration_since(UNIX_EPOCH) {
Ok(d) => d.as_millis(),
Err(_) => return false,
};
if mtime_ms >= now_ms {
return true;
}
let elapsed_secs = (now_ms - mtime_ms) / 1000;
elapsed_secs <= freshness_secs as u128
}
fn resolve_with_cache(
base: &Path,
cache_base: &Path,
session_key: &str,
cwd: &str,
now: SystemTime,
freshness: u64,
scan_days: usize,
) -> Option<CodexSession> {
let now_ms = cache_now_millis();
let freshness_secs = freshness.saturating_mul(60);
if let Some((written_ms, payload)) =
read_session_named_cache_in(cache_base, session_key, CODEX_CACHE_FILE)
{
if is_named_cache_fresh(written_ms, now_ms, freshness_secs) {
if let Ok(entry) = serde_json::from_str::<CodexCacheEntry>(&payload) {
let cached_path = PathBuf::from(&entry.path);
match file_mtime_ms(&cached_path) {
Some(current_mtime) if !is_mtime_fresh(current_mtime, now, freshness_secs) => {}
Some(current_mtime) if current_mtime == entry.mtime_ms => {
return Some(entry.session);
}
Some(current_mtime) => {
if let Some(session) = extract_from_file(&cached_path) {
write_cache_entry(
cache_base,
session_key,
&cached_path,
current_mtime,
&session,
now_ms,
);
return Some(session);
}
}
None => {}
}
}
}
}
match read_codex_session(base, cwd, now, freshness, scan_days) {
Resolution::Single(session, path) => {
if let Some(mtime) = file_mtime_ms(&path) {
write_cache_entry(cache_base, session_key, &path, mtime, &session, now_ms);
}
Some(session)
}
Resolution::Ambiguous | Resolution::None => None,
}
}
fn write_cache_entry(
cache_base: &Path,
session_key: &str,
path: &Path,
mtime_ms: u128,
session: &CodexSession,
now_ms: u128,
) {
let entry = CodexCacheEntry {
path: path.to_string_lossy().into_owned(),
mtime_ms,
session: session.clone(),
};
if let Ok(payload) = serde_json::to_string(&entry) {
write_session_named_cache_in(cache_base, session_key, CODEX_CACHE_FILE, now_ms, &payload);
}
}
fn codex_home() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("CODEX_HOME") {
return Some(PathBuf::from(path));
}
let home = std::env::var_os("HOME")?;
Some(PathBuf::from(home).join(".codex"))
}
fn is_codex_model(model: &str) -> bool {
model.trim().to_ascii_lowercase().starts_with("codex")
}
pub fn maybe_enrich(input: &mut ClaudeInput, cfg: &Config) {
maybe_enrich_in(input, cfg, None, None);
}
fn maybe_enrich_in(
input: &mut ClaudeInput,
cfg: &Config,
codex_base_override: Option<&Path>,
cache_base_override: Option<&Path>,
) {
if !cfg.codex.enabled {
return;
}
let is_codex = input
.model_display_name
.as_deref()
.map(is_codex_model)
.unwrap_or(false);
if !is_codex {
return;
}
let cwd = match input.cwd.as_deref() {
Some(cwd) if !cwd.is_empty() => cwd.to_string(),
_ => return,
};
let base = match codex_base_override.map(PathBuf::from).or_else(codex_home) {
Some(base) if base.exists() => base,
_ => {
debug_log("codex_home 부재 — enrich 생략");
return;
}
};
let session_key = input.session_id.clone().unwrap_or_else(|| cwd.clone());
let cache_base = cache_base_override
.map(PathBuf::from)
.or_else(crate::chain::cache_dir);
let resolved = match cache_base {
Some(cache_base) => resolve_with_cache(
&base,
&cache_base,
&session_key,
&cwd,
SystemTime::now(),
cfg.codex.freshness_minutes,
cfg.codex.scan_days,
),
None => match read_codex_session(
&base,
&cwd,
SystemTime::now(),
cfg.codex.freshness_minutes,
cfg.codex.scan_days,
) {
Resolution::Single(session, _path) => Some(session),
Resolution::Ambiguous | Resolution::None => None,
},
};
match resolved {
Some(session) => {
if let Some(model) = session.model {
input.model_display_name = Some(model);
}
input.context_used_percentage = session.context_percentage;
input.codex = Some(session.extras);
}
None => {
debug_log("Codex 세션 해소 실패/모호 — enrich 생략");
}
}
}
fn debug_log(message: &str) {
if std::env::var_os("LTERM_STATUS_DEBUG").is_some() {
eprintln!("understatus[codex]: {message}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::time::Duration;
fn session_meta_line(cwd: &str, originator: &str) -> String {
format!(
r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"{originator}","cli_version":"0.137.0"}}}}"#
)
}
fn big_session_meta_line(cwd: &str, originator: &str) -> String {
let big_instructions = "A".repeat(32 * 1024);
format!(
r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"{originator}","cli_version":"0.137.0","base_instructions":{{"text":"{big_instructions}"}}}}}}"#
)
}
fn turn_context_line(model: &str, effort: &str) -> String {
format!(
r#"{{"type":"turn_context","payload":{{"turn_id":"t1","model":"{model}","effort":"{effort}","summary":"auto"}}}}"#
)
}
fn token_count_line(
last_total: u64,
window: u64,
total_cumulative: u64,
rate_5h: f64,
rate_weekly: f64,
plan: &str,
) -> String {
format!(
r#"{{"type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"total_tokens":{total_cumulative}}},"last_token_usage":{{"input_tokens":1,"total_tokens":{last_total}}},"model_context_window":{window}}},"rate_limits":{{"limit_id":"codex","primary":{{"used_percent":{rate_5h},"window_minutes":300,"resets_at":1}},"secondary":{{"used_percent":{rate_weekly},"window_minutes":10080,"resets_at":2}},"plan_type":"{plan}"}}}}}}"#
)
}
fn unique_tmp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"understatus-codex-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("임시 디렉터리 생성 실패");
dir
}
fn write_rollout(base: &Path, tag: &str, lines: &[String]) -> PathBuf {
let day_dir = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&day_dir).expect("일자 디렉터리 생성 실패");
let path = day_dir.join(format!("rollout-2026-06-05T20-40-45-{tag}.jsonl"));
let mut file = std::fs::File::create(&path).expect("rollout 파일 생성 실패");
for line in lines {
writeln!(file, "{line}").expect("rollout 라인 쓰기 실패");
}
path
}
#[test]
fn parse_token_count_nested_info_27_5_percent() {
let line = token_count_line(11_000, 40_000, 9_999_999, 3.0, 21.0, "pro");
let snap = parse_token_count(&line).expect("token_count 파싱");
assert_eq!(snap.last_total_tokens, Some(11_000));
assert_eq!(snap.context_window, Some(40_000));
let ctx = compute_context_percentage(11_000, 40_000).expect("ctx%");
assert!((ctx - 27.5).abs() < 1e-9, "ctx%는 27.5여야 함: {ctx}");
}
#[test]
fn parse_token_count_ignores_total_token_usage() {
let line = token_count_line(11_000, 40_000, 84_000, 3.0, 21.0, "pro");
let snap = parse_token_count(&line).expect("token_count 파싱");
assert_eq!(snap.last_total_tokens, Some(11_000));
assert_ne!(
snap.last_total_tokens,
Some(84_000),
"total_token_usage(누적) 오용 금지"
);
let ctx = compute_context_percentage(snap.last_total_tokens.unwrap(), 40_000).unwrap();
assert!(ctx < 100.0, "100% 초과 불가(누적값 미사용): {ctx}");
}
#[test]
fn parse_token_count_identifies_rate_windows_by_minutes() {
let line = token_count_line(100, 1000, 0, 3.0, 21.0, "pro");
let snap = parse_token_count(&line).expect("token_count 파싱");
assert_eq!(snap.rate_5h_percent, Some(3.0));
assert_eq!(snap.rate_weekly_percent, Some(21.0));
assert_eq!(snap.plan.as_deref(), Some("pro"));
}
#[test]
fn parse_token_count_window_swap_still_identified() {
let line = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":5},"model_context_window":100},"rate_limits":{"primary":{"used_percent":55.0,"window_minutes":10080},"secondary":{"used_percent":7.0,"window_minutes":300}}}}"#;
let snap = parse_token_count(line).expect("token_count 파싱");
assert_eq!(snap.rate_5h_percent, Some(7.0));
assert_eq!(snap.rate_weekly_percent, Some(55.0));
}
#[test]
fn parse_token_count_missing_rate_limits_is_none() {
let line = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":5},"model_context_window":100}}}"#;
let snap = parse_token_count(line).expect("token_count 파싱");
assert_eq!(snap.rate_5h_percent, None);
assert_eq!(snap.rate_weekly_percent, None);
assert_eq!(snap.plan, None);
assert_eq!(snap.last_total_tokens, Some(5));
}
#[test]
fn parse_token_count_gating_rejects_non_token_count() {
assert!(parse_token_count(&turn_context_line("gpt-5.5", "high")).is_none());
let other = r#"{"type":"event_msg","payload":{"type":"agent_message","text":"hi"}}"#;
assert!(parse_token_count(other).is_none());
}
#[test]
fn compute_context_percentage_window_zero_is_none() {
assert_eq!(compute_context_percentage(100, 0), None);
assert_eq!(compute_context_percentage(0, 100), Some(0.0));
let half = compute_context_percentage(50, 100).unwrap();
assert!((half - 50.0).abs() < 1e-9);
}
#[test]
fn parse_session_meta_extracts_cwd_and_originator() {
let line = session_meta_line("/Users/me/proj", "codex-tui");
let meta = parse_session_meta(&line).expect("session_meta 파싱");
assert_eq!(meta.cwd.as_deref(), Some("/Users/me/proj"));
assert_eq!(meta.originator.as_deref(), Some("codex-tui"));
assert!(parse_session_meta(&turn_context_line("m", "e")).is_none());
}
#[test]
fn parse_turn_context_extracts_model_effort() {
let full = parse_turn_context(&turn_context_line("gpt-5.5", "xhigh")).expect("파싱");
assert_eq!(full.model.as_deref(), Some("gpt-5.5"));
assert_eq!(full.effort.as_deref(), Some("xhigh"));
let partial =
parse_turn_context(r#"{"type":"turn_context","payload":{"model":"gpt-5.5"}}"#)
.expect("부분 파싱");
assert_eq!(partial.model.as_deref(), Some("gpt-5.5"));
assert_eq!(partial.effort, None);
}
#[test]
fn interactive_originator_whitelist() {
assert!(is_interactive_originator(Some("codex-tui")));
assert!(is_interactive_originator(Some("codex-tui-0.137")));
assert!(!is_interactive_originator(Some("codex_exec")));
assert!(!is_interactive_originator(Some("codex-exec")));
assert!(!is_interactive_originator(None));
}
#[test]
fn drifted_or_broken_lines_no_panic() {
assert!(parse_token_count("{not json").is_none());
assert!(parse_session_meta("garbage").is_none());
assert!(parse_turn_context("[1,2,3]").is_none());
assert!(parse_token_count("").is_none());
let drift = r#"{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":"oops"},"model_context_window":"big"}}}"#;
let snap = parse_token_count(drift).expect("게이팅은 통과");
assert_eq!(snap.last_total_tokens, None);
assert_eq!(snap.context_window, None);
let versioned = r#"{"type":"turn_context","payload":{"model":"gpt-6","effort":"max","new_field_v999":{"x":1}}}"#;
let tc = parse_turn_context(versioned).expect("드리프트 무패닉");
assert_eq!(tc.model.as_deref(), Some("gpt-6"));
}
#[test]
fn is_mtime_fresh_judges_by_elapsed() {
let now = SystemTime::now();
let now_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis();
let freshness_secs = 240 * 60; assert!(is_mtime_fresh(now_ms - 60_000, now, freshness_secs));
assert!(!is_mtime_fresh(
now_ms - 5 * 3600 * 1000,
now,
freshness_secs
));
assert!(is_mtime_fresh(
now_ms - freshness_secs as u128 * 1000,
now,
freshness_secs
));
assert!(is_mtime_fresh(now_ms + 60_000, now, freshness_secs));
}
#[test]
fn cwd_matches_normalizes_trailing_slash() {
assert!(cwd_matches("/no/such/dir", "/no/such/dir/"));
assert!(cwd_matches("/no/such/dir/", "/no/such/dir"));
assert!(!cwd_matches("/no/such/dir", "/other/dir"));
}
#[test]
fn find_candidates_single_match() {
let base = unique_tmp("find-single");
let cwd = "/Users/me/projA";
write_rollout(
&base,
"single",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(100, 1000, 0, 3.0, 21.0, "pro"),
],
);
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(found.len(), 1, "단일 후보여야 함");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn find_candidates_with_huge_first_line() {
let base = unique_tmp("bigmeta");
let cwd = "/Users/me/projBigMeta";
write_rollout(
&base,
"bigmeta",
&[
big_session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
],
);
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(found.len(), 1, "거대 첫 줄도 cwd/originator 매칭 성공");
let session = extract_from_file(&found[0]).expect("추출");
assert_eq!(session.extras.rate_5h_percent, Some(3.0));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn ambiguous_two_same_cwd_candidates() {
let base = unique_tmp("ambiguous");
let cwd = "/Users/me/projDup";
for tag in ["dup1", "dup2"] {
write_rollout(
&base,
tag,
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(100, 1000, 0, 3.0, 21.0, "pro"),
],
);
}
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(found.len(), 2, "동일 cwd 2 후보");
let resolution = read_codex_session(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(resolution, Resolution::Ambiguous);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn stale_candidate_excluded() {
let base = unique_tmp("stale");
let cwd = "/Users/me/projStale";
let path = write_rollout(
&base,
"stale",
&[
session_meta_line(cwd, "codex-tui"),
token_count_line(100, 1000, 0, 3.0, 21.0, "pro"),
],
);
let two_hours_ago = SystemTime::now() - Duration::from_secs(2 * 3600);
set_file_mtime(&path, two_hours_ago);
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 60, 3);
assert_eq!(found.len(), 0, "stale 후보는 제외");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn scan_days_limits_directories() {
let base = unique_tmp("scandays");
let cwd = "/Users/me/projScan";
let new_day = base.join("sessions").join("2026").join("06").join("05");
let old_day = base.join("sessions").join("2026").join("06").join("01");
std::fs::create_dir_all(&new_day).unwrap();
std::fs::create_dir_all(&old_day).unwrap();
let lines = [
session_meta_line(cwd, "codex-tui"),
token_count_line(100, 1000, 0, 3.0, 21.0, "pro"),
];
for (dir, tag) in [(&new_day, "new"), (&old_day, "old")] {
let path = dir.join(format!("rollout-2026-06-05T20-40-45-{tag}.jsonl"));
let mut file = std::fs::File::create(&path).unwrap();
for line in &lines {
writeln!(file, "{line}").unwrap();
}
}
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 1);
assert_eq!(found.len(), 1, "scan_days=1은 최신 일자만 스캔");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn exec_originator_excluded() {
let base = unique_tmp("exec");
let cwd = "/Users/me/projExec";
write_rollout(
&base,
"exec",
&[
session_meta_line(cwd, "codex_exec"),
turn_context_line("gpt-5.5", "high"),
],
);
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(found.len(), 0, "exec originator는 제외");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn cwd_normalization_trailing_slash_matches() {
let base = unique_tmp("cwdnorm");
let real_cwd = base.join("realcwd");
std::fs::create_dir_all(&real_cwd).unwrap();
let cwd_str = real_cwd.to_string_lossy().into_owned();
write_rollout(
&base,
"cwdnorm",
&[
session_meta_line(&cwd_str, "codex-tui"),
token_count_line(100, 1000, 0, 3.0, 21.0, "pro"),
],
);
let target = format!("{cwd_str}/");
let found = find_codex_candidates(&base, &target, SystemTime::now(), 240, 3);
assert_eq!(found.len(), 1, "trailing slash 정규화 매칭");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn extract_combines_head_and_tail() {
let base = unique_tmp("extract");
let cwd = "/Users/me/projExtract";
let path = write_rollout(
&base,
"extract",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "low"), token_count_line(50, 1000, 0, 1.0, 5.0, "pro"),
turn_context_line("gpt-5.5", "xhigh"), token_count_line(275, 1000, 0, 3.0, 21.0, "pro"), ],
);
let session = extract_from_file(&path).expect("추출");
assert_eq!(session.model.as_deref(), Some("gpt-5.5"));
let ctx = session.context_percentage.expect("ctx%");
assert!((ctx - 27.5).abs() < 1e-9, "최신 token_count 우선: {ctx}");
assert_eq!(session.extras.effort.as_deref(), Some("xhigh"));
assert_eq!(session.extras.rate_5h_percent, Some(3.0));
assert_eq!(session.extras.rate_weekly_percent, Some(21.0));
assert_eq!(session.extras.plan.as_deref(), Some("pro"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn extract_huge_record_within_bounds() {
let base = unique_tmp("huge");
let cwd = "/Users/me/projHuge";
let big_summary = "x".repeat(132 * 1024);
let huge_line = format!(
r#"{{"type":"turn_context","payload":{{"model":"gpt-5.5","effort":"high","blob":"{big_summary}"}}}}"#
);
let path = write_rollout(
&base,
"huge",
&[
session_meta_line(cwd, "codex-tui"),
huge_line,
token_count_line(100, 1000, 0, 3.0, 21.0, "pro"),
],
);
let session = extract_from_file(&path).expect("거대 레코드 무패닉 추출");
assert_eq!(session.extras.rate_5h_percent, Some(3.0));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn extract_non_utf8_lossy() {
let base = unique_tmp("nonutf8");
let cwd = "/Users/me/projUtf";
let day_dir = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&day_dir).unwrap();
let path = day_dir.join("rollout-2026-06-05T20-40-45-utf.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, "{}", session_meta_line(cwd, "codex-tui")).unwrap();
file.write_all(&[0xFF, 0xFE, b'\n']).unwrap();
writeln!(file, "{}", token_count_line(100, 1000, 0, 3.0, 21.0, "pro")).unwrap();
let session = extract_from_file(&path).expect("비-UTF8 무패닉");
assert_eq!(session.extras.rate_5h_percent, Some(3.0));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn extract_no_token_count_partial() {
let base = unique_tmp("notoken");
let cwd = "/Users/me/projNew";
let path = write_rollout(
&base,
"notoken",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
],
);
let session = extract_from_file(&path).expect("추출");
assert_eq!(session.model.as_deref(), Some("gpt-5.5"));
assert_eq!(
session.context_percentage, None,
"token_count 전무 → ctx None"
);
assert_eq!(session.extras.rate_5h_percent, None);
assert_eq!(session.extras.effort.as_deref(), Some("high"));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn enrich_non_codex_no_change() {
let mut input = ClaudeInput {
model_display_name: Some("claude".to_string()),
cwd: Some("/tmp/x".to_string()),
session_id: Some("k1".to_string()),
..Default::default()
};
let before = input.clone();
maybe_enrich(&mut input, &Config::default());
assert_eq!(input, before, "non-codex는 무변경");
}
#[test]
fn enrich_disabled_no_change() {
let mut input = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some("/tmp/x".to_string()),
session_id: Some("k2".to_string()),
..Default::default()
};
let mut cfg = Config::default();
cfg.codex.enabled = false;
let before = input.clone();
maybe_enrich(&mut input, &cfg);
assert_eq!(input, before, "disabled는 무변경");
}
#[test]
fn enrich_single_candidate_sets_fields() {
let base = unique_tmp("enrich-single");
let cache_base = unique_tmp("enrich-single-cache");
let cwd = "/Users/me/projEnrich";
write_rollout(
&base,
"enrich",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "xhigh"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
],
);
let mut input = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.to_string()),
session_id: Some("enrich-single-key".to_string()),
..Default::default()
};
maybe_enrich_in(
&mut input,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(input.model_display_name.as_deref(), Some("gpt-5.5"));
let ctx = input.context_used_percentage.expect("ctx%");
assert!((ctx - 27.5).abs() < 1e-9, "ctx 27.5: {ctx}");
let extras = input.codex.expect("codex extras");
assert_eq!(extras.rate_5h_percent, Some(3.0));
assert_eq!(extras.rate_weekly_percent, Some(21.0));
assert_eq!(extras.plan.as_deref(), Some("pro"));
assert_eq!(extras.effort.as_deref(), Some("xhigh"));
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn enrich_ambiguous_no_change() {
let base = unique_tmp("enrich-amb");
let cache_base = unique_tmp("enrich-amb-cache");
let cwd = "/Users/me/projAmb";
for tag in ["a1", "a2"] {
write_rollout(
&base,
tag,
&[
session_meta_line(cwd, "codex-tui"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
],
);
}
let mut input = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.to_string()),
session_id: Some("enrich-amb-key".to_string()),
..Default::default()
};
let before = input.clone();
maybe_enrich_in(
&mut input,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(input, before, "모호는 무변경(model=codex 유지)");
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_steady_state_reuses_without_rescan() {
let base = unique_tmp("cache-steady");
let cache_base = unique_tmp("cache-steady-cache");
let cwd = "/Users/me/projCache";
let key = "cache-steady-key";
write_rollout(
&base,
"cache",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
],
);
let mut first = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.to_string()),
session_id: Some(key.to_string()),
..Default::default()
};
maybe_enrich_in(
&mut first,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(first.model_display_name.as_deref(), Some("gpt-5.5"));
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some("/no/match/here".to_string()),
session_id: Some(key.to_string()),
..Default::default()
};
maybe_enrich_in(
&mut second,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(
second.model_display_name.as_deref(),
Some("gpt-5.5"),
"정상상태는 캐시 재사용(재스캔 없이 stat 1회)"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_ignored_when_resolved_file_is_stale() {
let base = unique_tmp("cache-stale");
let cache_base = unique_tmp("cache-stale-cache");
let cwd = "/Users/me/projStaleCache";
let key = "cache-stale-key";
let rollout_path = write_rollout(
&base,
"cachestale",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
],
);
let mut first = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.to_string()),
session_id: Some(key.to_string()),
..Default::default()
};
maybe_enrich_in(
&mut first,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(
first.model_display_name.as_deref(),
Some("gpt-5.5"),
"1회차는 fresh 파일이라 enrich 성공"
);
let five_hours_ago = SystemTime::now() - Duration::from_secs(5 * 3600);
set_file_mtime(&rollout_path, five_hours_ago);
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some("/no/match/here".to_string()),
session_id: Some(key.to_string()),
..Default::default()
};
let before = second.clone();
maybe_enrich_in(
&mut second,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(
second, before,
"stale 캐시는 무시되어 종료된 세션이 더는 표시되지 않아야 함(model=codex 유지)"
);
assert_eq!(
second.model_display_name.as_deref(),
Some("codex"),
"stale 후 재해소 0 후보 → model 슬롯 미변경(bare codex)"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
fn set_file_mtime(path: &Path, time: SystemTime) {
let secs = time
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
let times = [
libc::timeval {
tv_sec: secs,
tv_usec: 0,
},
libc::timeval {
tv_sec: secs,
tv_usec: 0,
},
];
let c_path = std::ffi::CString::new(path.to_string_lossy().as_bytes()).unwrap();
unsafe {
libc::utimes(c_path.as_ptr(), times.as_ptr());
}
}
}