use std::collections::HashSet;
use std::io::{BufRead, BufReader};
use std::path::Path;
use super::{AgentDetector, AgentSnapshot, InstallStatus};
const ID: &str = "codex";
const DISPLAY_NAME: &str = "Codex CLI";
const MAX_LINES: usize = 200_000;
pub struct CodexDetector;
impl AgentDetector for CodexDetector {
fn id(&self) -> &'static str {
ID
}
fn display_name(&self) -> &'static str {
DISPLAY_NAME
}
fn detect(&self, home: &Path) -> AgentSnapshot {
let codex_dir = home.join(".codex");
let history = codex_dir.join("history.jsonl");
let paths_checked = vec![codex_dir.clone(), history.clone()];
let dir_present = codex_dir.is_dir();
let binary_present = which::which("codex").is_ok();
let status = if dir_present || binary_present {
InstallStatus::Yes
} else {
InstallStatus::No
};
if status == InstallStatus::No {
return empty(paths_checked);
}
let (sessions, last_used) = parse_history(&history);
AgentSnapshot {
id: ID,
display_name: DISPLAY_NAME,
status,
sessions: (sessions > 0).then_some(sessions),
last_used,
score: 0.0,
paths_checked,
}
}
}
fn parse_history(history: &Path) -> (u64, Option<chrono::DateTime<chrono::Utc>>) {
let Ok(file) = std::fs::File::open(history) else {
return (0, None);
};
let reader = BufReader::new(file);
let mut sessions: HashSet<String> = HashSet::new();
let mut last_ts: i64 = 0;
for line in reader.lines().map_while(Result::ok).take(MAX_LINES) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if let Some(sid) = value.get("session_id").and_then(|v| v.as_str()) {
sessions.insert(sid.to_string());
}
if let Some(ts) = value.get("ts").and_then(|v| v.as_i64())
&& ts > last_ts
{
last_ts = ts;
}
}
let last_used = if last_ts > 0 {
chrono::DateTime::<chrono::Utc>::from_timestamp(last_ts, 0)
} else {
None
};
(sessions.len() as u64, last_used)
}
fn empty(paths_checked: Vec<std::path::PathBuf>) -> AgentSnapshot {
AgentSnapshot {
id: ID,
display_name: DISPLAY_NAME,
status: InstallStatus::No,
sessions: None,
last_used: None,
score: 0.0,
paths_checked,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn counts_unique_session_ids() {
let home = tempdir().unwrap();
let codex = home.path().join(".codex");
fs::create_dir_all(&codex).unwrap();
let history = codex.join("history.jsonl");
let body = "\
{\"session_id\":\"AAA\",\"ts\":1700000000,\"text\":\"hi\"}
{\"session_id\":\"AAA\",\"ts\":1700000010,\"text\":\"again\"}
{\"session_id\":\"BBB\",\"ts\":1700000020,\"text\":\"third\"}
{\"session_id\":\"CCC\",\"ts\":1700000030,\"text\":\"fourth\"}
";
fs::write(&history, body).unwrap();
let snap = CodexDetector.detect(home.path());
assert_eq!(snap.status, InstallStatus::Yes);
assert_eq!(snap.sessions, Some(3));
assert_eq!(snap.last_used.unwrap().timestamp(), 1700000030);
}
#[test]
fn no_codex_dir_means_not_installed() {
let home = tempdir().unwrap();
let snap = CodexDetector.detect(home.path());
if which::which("codex").is_err() {
assert_eq!(snap.status, InstallStatus::No);
assert!(snap.sessions.is_none());
assert!(snap.last_used.is_none());
}
}
#[test]
fn empty_history_file_means_zero_sessions() {
let home = tempdir().unwrap();
let codex = home.path().join(".codex");
fs::create_dir_all(&codex).unwrap();
fs::write(codex.join("history.jsonl"), b"").unwrap();
let snap = CodexDetector.detect(home.path());
assert_eq!(snap.status, InstallStatus::Yes);
assert!(snap.sessions.is_none());
assert!(snap.last_used.is_none());
}
#[test]
fn malformed_json_lines_are_skipped() {
let home = tempdir().unwrap();
let codex = home.path().join(".codex");
fs::create_dir_all(&codex).unwrap();
let body = "\
not json at all
{\"session_id\":\"X\",\"ts\":1700000000,\"text\":\"valid\"}
{this is also broken}
";
fs::write(codex.join("history.jsonl"), body).unwrap();
let snap = CodexDetector.detect(home.path());
assert_eq!(snap.sessions, Some(1));
assert_eq!(snap.last_used.unwrap().timestamp(), 1700000000);
}
#[test]
fn missing_history_file_yields_no_session_data_but_install_yes() {
let home = tempdir().unwrap();
let codex = home.path().join(".codex");
fs::create_dir_all(&codex).unwrap();
let snap = CodexDetector.detect(home.path());
assert_eq!(snap.status, InstallStatus::Yes);
assert!(snap.sessions.is_none());
assert!(snap.last_used.is_none());
}
}