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::{BufRead, BufReader, Error, ErrorKind, 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 MAX_CODEX_SCAN_ENTRIES: usize = 1024;
const MAX_CODEX_DATE_DIR_ENTRIES: usize = 512;
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 CandidateScan {
candidates: Vec<PathBuf>,
stale_same_cwd_rollouts: Vec<WatchedRollout>,
fingerprint: CodexScanFingerprint,
budget_exceeded: bool,
scan_incomplete: bool,
}
struct RecentDayDirs {
dirs: Vec<PathBuf>,
budget_exceeded: bool,
scan_incomplete: bool,
}
struct BoundedSubdirs {
dirs: Vec<PathBuf>,
budget_exceeded: bool,
scan_incomplete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CodexScanFingerprint {
days: Vec<CodexDayFingerprint>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CodexDayFingerprint {
path: String,
mtime_ms: Option<u128>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct WatchedRollout {
path: String,
}
#[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 normalize_cwd_for_match(cwd: &str) -> PathBuf {
std::fs::canonicalize(cwd).unwrap_or_else(|_| PathBuf::from(cwd.trim_end_matches('/')))
}
fn cwd_matches_normalized(candidate_cwd: &str, normalized_target_cwd: &Path) -> bool {
normalize_cwd_for_match(candidate_cwd) == normalized_target_cwd
}
#[cfg(test)]
fn cwd_matches(candidate_cwd: &str, target_cwd: &str) -> bool {
cwd_matches_normalized(candidate_cwd, &normalize_cwd_for_match(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,
},
})
}
#[cfg(test)]
fn find_codex_candidates(
base: &Path,
cwd: &str,
now: SystemTime,
freshness: u64,
scan_days: usize,
) -> Vec<PathBuf> {
scan_codex_candidates(base, cwd, now, freshness, scan_days).candidates
}
fn scan_codex_candidates(
base: &Path,
cwd: &str,
now: SystemTime,
freshness: u64,
scan_days: usize,
) -> CandidateScan {
let (rollout_paths, fingerprint, budget_exceeded, mut scan_incomplete) =
collect_rollout_scan(base, scan_days, true);
let freshness_secs = freshness.saturating_mul(60);
let normalized_target_cwd = normalize_cwd_for_match(cwd);
let mut candidates = Vec::new();
let mut stale_same_cwd_rollouts = Vec::new();
if budget_exceeded || scan_incomplete {
return CandidateScan {
candidates,
stale_same_cwd_rollouts,
fingerprint,
budget_exceeded,
scan_incomplete,
};
}
for path in rollout_paths {
let fresh = match is_fresh_checked(&path, now, freshness_secs) {
Ok(fresh) => fresh,
Err(_) => {
scan_incomplete = true;
break;
}
};
match read_first_line_meta_checked(&path) {
Ok(Some(meta)) => {
let cwd_ok = meta
.cwd
.as_deref()
.map(|c| cwd_matches_normalized(c, &normalized_target_cwd))
.unwrap_or(false);
let originator_ok = is_interactive_originator(meta.originator.as_deref());
if fresh && cwd_ok && originator_ok {
candidates.push(path);
} else if !fresh && cwd_ok && originator_ok {
stale_same_cwd_rollouts.push(WatchedRollout {
path: path.to_string_lossy().into_owned(),
});
}
}
Ok(None) => {}
Err(_) => {
scan_incomplete = true;
break;
}
}
}
CandidateScan {
candidates,
stale_same_cwd_rollouts,
fingerprint,
budget_exceeded,
scan_incomplete,
}
}
fn current_scan_fingerprint(base: &Path, scan_days: usize) -> Option<CodexScanFingerprint> {
let (_rollout_paths, fingerprint, budget_exceeded, scan_incomplete) =
collect_rollout_scan(base, scan_days, false);
if budget_exceeded || scan_incomplete {
return None;
}
Some(fingerprint)
}
fn collect_rollout_scan(
base: &Path,
scan_days: usize,
collect_paths: bool,
) -> (Vec<PathBuf>, CodexScanFingerprint, bool, bool) {
let sessions_dir = base.join("sessions");
let recent = recent_day_dirs(&sessions_dir, scan_days);
let mut rollout_paths = Vec::new();
let mut days = Vec::new();
let mut entries_seen = 0usize;
let mut budget_exceeded = recent.budget_exceeded;
let mut scan_incomplete = recent.scan_incomplete;
for day_dir in recent.dirs {
let day = match codex_day_fingerprint_checked(&day_dir) {
Ok(day) => day,
Err(_) => {
scan_incomplete = true;
CodexDayFingerprint {
path: day_dir.to_string_lossy().into_owned(),
mtime_ms: None,
}
}
};
if budget_exceeded || scan_incomplete {
days.push(day);
break;
}
let entries = match std::fs::read_dir(&day_dir) {
Ok(entries) => entries,
Err(_) => {
scan_incomplete = true;
days.push(day);
break;
}
};
for entry_result in entries {
let entry = match entry_result {
Ok(entry) => entry,
Err(_) => {
scan_incomplete = true;
break;
}
};
if entries_seen >= MAX_CODEX_SCAN_ENTRIES {
budget_exceeded = true;
break;
}
entries_seen += 1;
let path = entry.path();
match is_rollout_file(&path) {
Ok(true) if collect_paths => rollout_paths.push(path),
Ok(_) => {}
Err(_) => {
scan_incomplete = true;
break;
}
}
}
days.push(day);
if budget_exceeded || scan_incomplete {
break;
}
}
(
rollout_paths,
CodexScanFingerprint { days },
budget_exceeded,
scan_incomplete,
)
}
fn codex_day_fingerprint_checked(day_dir: &Path) -> std::io::Result<CodexDayFingerprint> {
Ok(CodexDayFingerprint {
path: day_dir.to_string_lossy().into_owned(),
mtime_ms: Some(file_mtime_ms_checked(day_dir)?),
})
}
fn recent_day_dirs(sessions_dir: &Path, scan_days: usize) -> RecentDayDirs {
let mut result = Vec::new();
if scan_days == 0 {
return RecentDayDirs {
dirs: result,
budget_exceeded: false,
scan_incomplete: false,
};
}
let year_dirs = sorted_subdirs_desc_bounded(sessions_dir);
if year_dirs.budget_exceeded || year_dirs.scan_incomplete {
return RecentDayDirs {
dirs: result,
budget_exceeded: year_dirs.budget_exceeded,
scan_incomplete: year_dirs.scan_incomplete,
};
}
for year_dir in year_dirs.dirs {
let month_dirs = sorted_subdirs_desc_bounded(&year_dir);
if month_dirs.budget_exceeded || month_dirs.scan_incomplete {
return RecentDayDirs {
dirs: result,
budget_exceeded: month_dirs.budget_exceeded,
scan_incomplete: month_dirs.scan_incomplete,
};
}
for month_dir in month_dirs.dirs {
let day_dirs = sorted_subdirs_desc_bounded(&month_dir);
if day_dirs.budget_exceeded || day_dirs.scan_incomplete {
return RecentDayDirs {
dirs: result,
budget_exceeded: day_dirs.budget_exceeded,
scan_incomplete: day_dirs.scan_incomplete,
};
}
for day_dir in day_dirs.dirs {
result.push(day_dir);
if result.len() >= scan_days {
return RecentDayDirs {
dirs: result,
budget_exceeded: false,
scan_incomplete: false,
};
}
}
}
}
RecentDayDirs {
dirs: result,
budget_exceeded: false,
scan_incomplete: false,
}
}
fn sorted_subdirs_desc_bounded(dir: &Path) -> BoundedSubdirs {
let mut subdirs: Vec<PathBuf> = match std::fs::read_dir(dir) {
Ok(entries) => {
let mut subdirs = Vec::new();
for (entries_seen, entry_result) in entries.enumerate() {
if entries_seen >= MAX_CODEX_DATE_DIR_ENTRIES {
return BoundedSubdirs {
dirs: Vec::new(),
budget_exceeded: true,
scan_incomplete: false,
};
}
let entry = match entry_result {
Ok(entry) => entry,
Err(_) => {
return BoundedSubdirs {
dirs: Vec::new(),
budget_exceeded: false,
scan_incomplete: true,
};
}
};
let path = entry.path();
match path.metadata() {
Ok(meta) if meta.is_dir() => subdirs.push(path),
Ok(_) => {}
Err(_) => {
return BoundedSubdirs {
dirs: Vec::new(),
budget_exceeded: false,
scan_incomplete: true,
};
}
}
}
subdirs
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(_) => {
return BoundedSubdirs {
dirs: Vec::new(),
budget_exceeded: false,
scan_incomplete: true,
};
}
};
subdirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
BoundedSubdirs {
dirs: subdirs,
budget_exceeded: false,
scan_incomplete: false,
}
}
fn is_rollout_file(path: &Path) -> std::io::Result<bool> {
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => return Ok(false),
};
if !(name.starts_with("rollout-") && name.ends_with(".jsonl")) {
return Ok(false);
}
path.metadata().map(|meta| meta.is_file())
}
fn is_fresh_checked(path: &Path, now: SystemTime, freshness_secs: u64) -> std::io::Result<bool> {
let modified = path.metadata().and_then(|m| m.modified())?;
match now.duration_since(modified) {
Ok(elapsed) => Ok(elapsed.as_secs() <= freshness_secs),
Err(_) => Ok(true),
}
}
#[cfg(test)]
fn read_first_line_meta(path: &Path) -> Option<SessionMeta> {
read_first_line_meta_checked(path).ok().flatten()
}
fn read_first_line_meta_checked(path: &Path) -> Result<Option<SessionMeta>, ()> {
let file = File::open(path).map_err(|_| ())?;
let reader = BufReader::new(file);
let mut limited = reader.take(FIRST_LINE_READ_BYTES);
let mut buf = Vec::with_capacity(4 * 1024);
let bytes_read = limited.read_until(b'\n', &mut buf).map_err(|_| ())?;
if bytes_read == 0 {
return Ok(None);
}
let has_newline = buf.last() == Some(&b'\n');
if !has_newline {
return Err(());
}
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
let first_line = String::from_utf8_lossy(&buf);
parse_session_meta(&first_line).map(Some).ok_or(())
}
fn read_codex_session(
base: &Path,
cwd: &str,
now: SystemTime,
freshness: u64,
scan_days: usize,
) -> Resolution {
let scan = scan_codex_candidates(base, cwd, now, freshness, scan_days);
resolution_from_scan(&scan)
}
fn resolution_from_scan(scan: &CandidateScan) -> Resolution {
if scan.budget_exceeded || scan.scan_incomplete {
return Resolution::Ambiguous;
}
match scan.candidates.len() {
0 => Resolution::None,
1 => {
let path = &scan.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,
#[serde(default)]
matched_cwd: Option<String>,
#[serde(default)]
scan_fingerprint: Option<CodexScanFingerprint>,
#[serde(default)]
stale_same_cwd_rollouts: Vec<WatchedRollout>,
session: CodexSession,
}
fn file_mtime_ms(path: &Path) -> Option<u128> {
file_mtime_ms_checked(path).ok()
}
fn file_mtime_ms_checked(path: &Path) -> std::io::Result<u128> {
let modified = path.metadata().and_then(|m| m.modified())?;
modified
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.map_err(|_| Error::new(ErrorKind::InvalidData, "mtime precedes unix epoch"))
}
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 watched_rollouts_still_stale(
watched: &[WatchedRollout],
now: SystemTime,
freshness_secs: u64,
) -> bool {
watched.iter().all(|rollout| {
let path = PathBuf::from(&rollout.path);
match file_mtime_ms_checked(&path) {
Ok(mtime) => !is_mtime_fresh(mtime, now, freshness_secs),
Err(err) if err.kind() == ErrorKind::NotFound => {
std::fs::symlink_metadata(&path).is_err()
}
Err(_) => false,
}
})
}
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);
let normalized_cwd = normalize_cwd_for_match(cwd);
let normalized_cwd_key = normalized_cwd.to_string_lossy().into_owned();
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 cwd_matches_cache = entry.matched_cwd.as_deref() == Some(&normalized_cwd_key);
if cwd_matches_cache {
let current_fingerprint = current_scan_fingerprint(base, scan_days);
let fingerprint_stable = entry
.scan_fingerprint
.as_ref()
.zip(current_fingerprint.as_ref())
.map(|(cached, current)| cached == current)
.unwrap_or(false);
let watched_still_stale = watched_rollouts_still_stale(
&entry.stale_same_cwd_rollouts,
now,
freshness_secs,
);
if !fingerprint_stable || !watched_still_stale {
} else {
let cached_path = PathBuf::from(&entry.path);
if !matches!(is_rollout_file(&cached_path), Ok(true)) {
} else {
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,
CodexCacheEntry {
path: cached_path.to_string_lossy().into_owned(),
mtime_ms: current_mtime,
matched_cwd: Some(normalized_cwd_key.clone()),
scan_fingerprint: current_fingerprint.clone(),
stale_same_cwd_rollouts: entry
.stale_same_cwd_rollouts
.clone(),
session: session.clone(),
},
now_ms,
);
return Some(session);
}
}
None => {}
}
}
}
}
}
}
}
let scan = scan_codex_candidates(base, cwd, now, freshness, scan_days);
match resolution_from_scan(&scan) {
Resolution::Single(session, path) => {
if let Some(mtime) = file_mtime_ms(&path) {
write_cache_entry(
cache_base,
session_key,
CodexCacheEntry {
path: path.to_string_lossy().into_owned(),
mtime_ms: mtime,
matched_cwd: Some(normalized_cwd_key.clone()),
scan_fingerprint: Some(scan.fingerprint.clone()),
stale_same_cwd_rollouts: scan.stale_same_cwd_rollouts.clone(),
session: session.clone(),
},
now_ms,
);
}
Some(session)
}
Resolution::Ambiguous | Resolution::None => None,
}
}
fn write_cache_entry(cache_base: &Path, session_key: &str, entry: CodexCacheEntry, now_ms: u128) {
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 find_candidates_rejects_unterminated_over_limit_first_line() {
let base = unique_tmp("bigmeta-no-newline");
let cwd = "/Users/me/projBigMetaNoNewline";
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-bigmeta-no-newline.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
write!(
file,
r#"{{"timestamp":"2026-06-05T11:41:50.379Z","type":"session_meta","payload":{{"id":"abc","cwd":"{cwd}","originator":"codex-tui","base_instructions":{{"text":""#
)
.unwrap();
write!(file, "{}", "A".repeat(FIRST_LINE_READ_BYTES as usize)).unwrap();
file.flush().unwrap();
assert!(
read_first_line_meta(&path).is_none(),
"개행 없이 상한에 걸린 첫 줄은 부분 session_meta로 파싱하면 안 됨"
);
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.scan_incomplete,
"미완결 과대 첫 줄은 후보 없음이 아니라 불완전 스캔으로 전파되어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"미완결 과대 첫 줄은 실제 해소 경로에서 fail-safe"
);
let found = find_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(
found.len(),
0,
"미완결 과대 첫 줄은 cwd/originator 후보에서 제외"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn short_unterminated_first_line_fails_safe() {
let base = unique_tmp("short-no-newline");
let cwd = "/Users/me/projShortNoNewline";
write_rollout(
&base,
"short-valid",
&[
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 day_dir = base.join("sessions").join("2026").join("06").join("05");
let partial_path = day_dir.join("rollout-2026-06-05T20-40-45-short-partial.jsonl");
let mut partial = std::fs::File::create(&partial_path).unwrap();
write!(partial, "{}", session_meta_line(cwd, "codex-tui")).unwrap();
partial.flush().unwrap();
assert!(
read_first_line_meta(&partial_path).is_none(),
"짧더라도 개행 없는 첫 줄은 legacy API에서 후보 없음으로 보임"
);
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.scan_incomplete,
"짧은 미완결 첫 줄도 scan_incomplete로 전파되어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"다른 valid 후보가 있어도 부분 라인 rollout이 있으면 fail-safe"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn malformed_first_line_fails_safe() {
let base = unique_tmp("malformed-first-line");
let cwd = "/Users/me/projMalformedFirstLine";
write_rollout(
&base,
"malformed-valid",
&[
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 day_dir = base.join("sessions").join("2026").join("06").join("05");
let malformed_path = day_dir.join("rollout-2026-06-05T20-40-45-malformed.jsonl");
let mut malformed = std::fs::File::create(&malformed_path).unwrap();
writeln!(malformed, "{{not json").unwrap();
malformed.flush().unwrap();
assert!(
read_first_line_meta(&malformed_path).is_none(),
"legacy parser API는 malformed first line을 None으로 노출"
);
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.scan_incomplete,
"malformed first line은 scan_incomplete로 전파되어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"다른 valid 후보가 있어도 malformed rollout이 있으면 fail-safe"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn rollout_metadata_error_fails_safe() {
let base = unique_tmp("rollout-metadata-error");
let cwd = "/Users/me/projRolloutMetadataError";
let day_dir = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&day_dir).unwrap();
let candidate_path = day_dir.join("rollout-2026-06-05T20-40-45-candidate.jsonl");
let mut candidate = std::fs::File::create(&candidate_path).unwrap();
for line in [
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
] {
writeln!(candidate, "{line}").unwrap();
}
let broken = day_dir.join("rollout-2026-06-05T20-40-45-broken.jsonl");
std::os::unix::fs::symlink(day_dir.join("missing-target.jsonl"), &broken).unwrap();
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.scan_incomplete,
"rollout-*.jsonl metadata 실패는 scan_incomplete로 전파되어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"metadata 실패가 있는 부분 스캔에서 본 단일 후보를 표시하면 안 됨"
);
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 scan_budget_exceeded_fails_safe() {
let base = unique_tmp("scan-budget");
let cwd = "/Users/me/projBudget";
let day_dir = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&day_dir).unwrap();
for idx in 0..=MAX_CODEX_SCAN_ENTRIES {
let path = day_dir.join(format!("rollout-2026-06-05T20-40-45-{idx:04}.jsonl"));
std::fs::File::create(path).unwrap();
}
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.budget_exceeded,
"디렉터리 엔트리가 상한을 넘으면 budget_exceeded가 기록되어야 함"
);
let resolution = read_codex_session(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(
resolution,
Resolution::Ambiguous,
"부분 스캔으로 단일/없음을 확정하지 않고 enrich 생략 경로로 저하"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn scan_budget_with_prior_candidate_still_fails_safe() {
let base = unique_tmp("scan-budget-prior-candidate");
let cache_base = unique_tmp("scan-budget-prior-candidate-cache");
let cwd = "/Users/me/projBudgetPriorCandidate";
let newest_day = base.join("sessions").join("2026").join("06").join("06");
let older_day = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&newest_day).unwrap();
std::fs::create_dir_all(&older_day).unwrap();
let candidate_path = newest_day.join("rollout-2026-06-06T20-40-45-candidate.jsonl");
let mut candidate = std::fs::File::create(&candidate_path).unwrap();
for line in [
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.5", "high"),
token_count_line(275, 1000, 0, 3.0, 21.0, "pro"),
] {
writeln!(candidate, "{line}").unwrap();
}
for idx in 0..MAX_CODEX_SCAN_ENTRIES {
let path = older_day.join(format!("noise-{idx:04}.txt"));
std::fs::File::create(path).unwrap();
}
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.budget_exceeded,
"후보를 먼저 발견했더라도 뒤따른 디렉터리 엔트리 폭증은 budget_exceeded여야 함"
);
assert!(
scan.candidates.is_empty(),
"예산 초과 시에는 이미 존재하는 valid 후보도 신뢰 가능한 단일 후보로 노출하지 않음"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"부분 스캔에서 본 단일 후보를 표시하지 않고 Ambiguous로 저하"
);
let mut input = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.to_string()),
session_id: Some("scan-budget-prior-candidate-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,
"budget_exceeded 상태에서는 이미 발견한 단일 후보로도 enrich하지 않아야 함"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn scan_budget_counts_non_rollout_entries() {
let base = unique_tmp("scan-budget-noise");
let cwd = "/Users/me/projBudgetNoise";
let day_dir = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&day_dir).unwrap();
for idx in 0..=MAX_CODEX_SCAN_ENTRIES {
let path = day_dir.join(format!("noise-{idx:04}.txt"));
std::fs::File::create(path).unwrap();
}
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.budget_exceeded,
"non-rollout 파일이 많아도 총 디렉터리 엔트리 예산을 초과해야 함"
);
assert_eq!(scan.candidates.len(), 0);
let resolution = read_codex_session(&base, cwd, SystemTime::now(), 240, 3);
assert_eq!(
resolution,
Resolution::Ambiguous,
"non-rollout 엔트리 폭증도 부분 스캔이므로 fail-safe"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn scan_budget_exact_limit_is_allowed() {
let base = unique_tmp("scan-budget-exact");
let cwd = "/Users/me/projBudgetExact";
let day_dir = base.join("sessions").join("2026").join("06").join("05");
std::fs::create_dir_all(&day_dir).unwrap();
for idx in 0..MAX_CODEX_SCAN_ENTRIES {
let path = day_dir.join(format!("noise-{idx:04}.txt"));
std::fs::File::create(path).unwrap();
}
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
!scan.budget_exceeded,
"정확히 상한만큼의 엔트리는 budget_exceeded가 아니어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::None
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn date_discovery_budget_exceeded_fails_safe() {
let base = unique_tmp("date-budget");
let cwd = "/Users/me/projDateBudget";
let sessions_dir = base.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
for idx in 0..=MAX_CODEX_DATE_DIR_ENTRIES {
std::fs::create_dir_all(sessions_dir.join(format!("{idx:04}"))).unwrap();
}
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.budget_exceeded,
"date discovery 엔트리 상한을 넘으면 budget_exceeded가 기록되어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"date discovery 부분 스캔도 fail-safe로 저하"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn date_discovery_read_error_fails_safe() {
let base = unique_tmp("date-read-error");
let cwd = "/Users/me/projDateReadError";
let sessions_path = base.join("sessions");
std::fs::create_dir_all(&base).unwrap();
std::fs::write(&sessions_path, b"not a directory").unwrap();
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.scan_incomplete,
"date discovery read_dir 실패는 scan_incomplete로 기록되어야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"부분/불완전 스캔은 후보 없음으로 오판하지 않고 fail-safe"
);
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(cwd.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"),
"정상상태는 캐시 재사용"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_hit_with_different_cwd_does_not_reuse() {
let base = unique_tmp("cache-cwd");
let cache_base = unique_tmp("cache-cwd-cache");
let cwd = "/Users/me/projCacheCwd";
let key = "cache-cwd-key";
write_rollout(
&base,
"cachecwd",
&[
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()
};
let before = second.clone();
maybe_enrich_in(
&mut second,
&Config::default(),
Some(&base),
Some(&cache_base),
);
assert_eq!(
second, before,
"동일 key라도 cwd가 다르면 cached Codex session을 표시하면 안 됨"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_hit_rechecks_new_same_cwd_rollout_before_reuse() {
let base = unique_tmp("cache-amb");
let cache_base = unique_tmp("cache-amb-cache");
let cwd = "/Users/me/projCacheAmb";
let key = "cache-amb-key";
write_rollout(
&base,
"cacheamb1",
&[
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 second_rollout = write_rollout(
&base,
"cacheamb2",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.4-mini", "medium"),
token_count_line(100, 1000, 0, 2.0, 10.0, "pro"),
],
);
if let Some(day_dir) = second_rollout.parent() {
set_file_mtime(day_dir, SystemTime::now() + Duration::from_secs(1));
}
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.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,
"새 같은-cwd 후보가 생긴 뒤에는 캐시 재사용 대신 Ambiguous로 안전 저하"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_hit_rechecks_stale_same_cwd_rollout_becoming_fresh() {
let base = unique_tmp("cache-stale-watch");
let cache_base = unique_tmp("cache-stale-watch-cache");
let cwd = "/Users/me/projCacheStaleWatch";
let key = "cache-stale-watch-key";
let fresh_path = write_rollout(
&base,
"fresh",
&[
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 stale_path = write_rollout(
&base,
"stale-watch",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.4-mini", "medium"),
token_count_line(100, 1000, 0, 2.0, 10.0, "pro"),
],
);
let day_dir = fresh_path.parent().expect("day dir").to_path_buf();
let stable_day_mtime = SystemTime::now() - Duration::from_secs(30);
let stale_time = SystemTime::now() - Duration::from_secs(5 * 3600);
set_file_mtime(&stale_path, stale_time);
set_file_mtime(&day_dir, stable_day_mtime);
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"));
set_file_mtime(&stale_path, SystemTime::now() + Duration::from_secs(1));
set_file_mtime(&day_dir, stable_day_mtime);
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.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 동일-cwd rollout이 fresh가 되면 cached 단일 세션 대신 Ambiguous로 저하"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[cfg(unix)]
#[test]
fn cache_hit_rejects_watched_rollout_stat_failure() {
let base = unique_tmp("cache-watch-stat-failure");
let cache_base = unique_tmp("cache-watch-stat-failure-cache");
let cwd = "/Users/me/projCacheWatchStatFailure";
let key = "cache-watch-stat-failure-key";
let fresh_path = write_rollout(
&base,
"watchfresh",
&[
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 stale_path = write_rollout(
&base,
"watchbroken",
&[
session_meta_line(cwd, "codex-tui"),
turn_context_line("gpt-5.4-mini", "medium"),
token_count_line(100, 1000, 0, 2.0, 10.0, "pro"),
],
);
let day_dir = fresh_path.parent().expect("day dir").to_path_buf();
let stable_day_mtime = SystemTime::now() - Duration::from_secs(30);
set_file_mtime(
&stale_path,
SystemTime::now() - Duration::from_secs(5 * 3600),
);
set_file_mtime(&day_dir, stable_day_mtime);
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"));
std::fs::remove_file(&stale_path).unwrap();
std::os::unix::fs::symlink(day_dir.join("missing-watch-target.jsonl"), &stale_path)
.unwrap();
set_file_mtime(&day_dir, stable_day_mtime);
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.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,
"watched rollout stat 실패는 cached 단일 세션 재사용 대신 fail-safe로 저하"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[cfg(unix)]
#[test]
fn day_dir_mtime_error_invalidates_cache_and_scan() {
let base = unique_tmp("cache-day-mtime-error");
let cache_base = unique_tmp("cache-day-mtime-error-cache");
let cwd = "/Users/me/projCacheDayMtimeError";
let key = "cache-day-mtime-error-key";
let rollout_path = write_rollout(
&base,
"daymtime",
&[
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 day_dir = rollout_path.parent().expect("day dir").to_path_buf();
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"));
set_file_mtime_before_epoch(&day_dir);
assert!(
current_scan_fingerprint(&base, 3).is_none(),
"day-dir mtime 불확실성은 cache fingerprint 재사용을 금지해야 함"
);
let scan = scan_codex_candidates(&base, cwd, SystemTime::now(), 240, 3);
assert!(
scan.scan_incomplete,
"day-dir mtime 불확실성은 full scan에서도 scan_incomplete여야 함"
);
assert_eq!(
read_codex_session(&base, cwd, SystemTime::now(), 240, 3),
Resolution::Ambiguous,
"mtime 불확실성이 있으면 valid 후보가 있어도 fail-safe"
);
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.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,
"day-dir mtime 불확실성은 cached 단일 세션 재사용 대신 fail-safe"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_hit_rejects_cached_path_file_type_change() {
let base = unique_tmp("cache-file-type");
let cache_base = unique_tmp("cache-file-type-cache");
let cwd = "/Users/me/projCacheFileType";
let key = "cache-file-type-key";
let rollout_path = write_rollout(
&base,
"filetype",
&[
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 day_dir = rollout_path.parent().expect("day dir").to_path_buf();
let stable_rollout_mtime = SystemTime::now() - Duration::from_secs(30);
let stable_day_mtime = SystemTime::now() - Duration::from_secs(20);
set_file_mtime(&rollout_path, stable_rollout_mtime);
set_file_mtime(&day_dir, stable_day_mtime);
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"));
std::fs::remove_file(&rollout_path).unwrap();
std::fs::create_dir(&rollout_path).unwrap();
set_file_mtime(&rollout_path, stable_rollout_mtime);
set_file_mtime(&day_dir, stable_day_mtime);
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.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,
"cached path가 directory로 바뀌면 mtime이 같아도 cached Codex session을 재사용하면 안 됨"
);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&cache_base);
}
#[test]
fn cache_hit_rechecks_scan_budget_even_when_day_mtime_stable() {
let base = unique_tmp("cache-hit-budget");
let cache_base = unique_tmp("cache-hit-budget-cache");
let cwd = "/Users/me/projCacheHitBudget";
let key = "cache-hit-budget-key";
let rollout_path = write_rollout(
&base,
"hitbudget",
&[
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 day_dir = rollout_path.parent().expect("day dir").to_path_buf();
let stable_day_mtime = SystemTime::now() - Duration::from_secs(20);
set_file_mtime(&day_dir, stable_day_mtime);
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"));
for idx in 0..MAX_CODEX_SCAN_ENTRIES {
let path = day_dir.join(format!("noise-cache-hit-{idx:04}.txt"));
std::fs::File::create(path).unwrap();
}
set_file_mtime(&day_dir, stable_day_mtime);
assert!(
current_scan_fingerprint(&base, 3).is_none(),
"cache-hit fingerprint probe must fail closed when current scan budget is exceeded"
);
let mut second = ClaudeInput {
model_display_name: Some("codex".to_string()),
cwd: Some(cwd.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,
"day mtime이 안정적이어도 current scan budget 초과 시 cached 단일 세션을 재사용하면 안 됨"
);
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(cwd.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());
}
}
#[cfg(unix)]
fn set_file_mtime_before_epoch(path: &Path) {
let times = [
libc::timeval {
tv_sec: -1,
tv_usec: 0,
},
libc::timeval {
tv_sec: -1,
tv_usec: 0,
},
];
let c_path = std::ffi::CString::new(path.to_string_lossy().as_bytes()).unwrap();
let rc = unsafe { libc::utimes(c_path.as_ptr(), times.as_ptr()) };
assert_eq!(rc, 0, "pre-epoch mtime 설정 실패");
}
}