use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub use franken_agent_detection::{
Connector,
DetectionResult,
DiscoveredSourceFile,
DiscoveredSourceRole,
ExtractedTokenUsage,
LOCAL_SOURCE_ID,
ModelInfo,
NormalizedConversation,
NormalizedMessage,
NormalizedSnippet,
Origin,
PathMapping,
PathTrie,
Platform,
ScanContext,
ScanRoot,
SourceKind,
TokenDataSource,
WorkspaceCache,
estimate_tokens_from_content,
extract_claude_code_tokens,
extract_codex_tokens,
extract_tokens_for_agent,
file_modified_since,
flatten_content,
franken_detection_for_connector,
get_connector_factories,
normalize_model,
parse_timestamp,
reindex_messages,
};
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct CodexScanPreflight {
pub scan_roots: Vec<ScanRoot>,
pub original_roots: usize,
pub explicit_file_roots: usize,
pub fallback_roots: usize,
}
#[doc(hidden)]
#[must_use]
pub fn preflight_codex_explicit_file_roots(
roots: &[ScanRoot],
since_ts: Option<i64>,
) -> CodexScanPreflight {
let mut scan_roots = Vec::new();
let mut explicit_file_roots = 0usize;
let mut fallback_roots = 0usize;
for root in roots {
if root.path.is_file() {
if is_codex_rollout_file(&root.path) {
explicit_file_roots = explicit_file_roots.saturating_add(1);
}
scan_roots.push(root.clone());
continue;
}
match codex_explicit_file_roots_for_root(root, since_ts) {
Ok(expanded) => {
explicit_file_roots = explicit_file_roots.saturating_add(expanded.len());
scan_roots.extend(expanded);
}
Err(_) => {
fallback_roots = fallback_roots.saturating_add(1);
scan_roots.push(root.clone());
}
}
}
CodexScanPreflight {
scan_roots,
original_roots: roots.len(),
explicit_file_roots,
fallback_roots,
}
}
fn codex_explicit_file_roots_for_root(
root: &ScanRoot,
since_ts: Option<i64>,
) -> io::Result<Vec<ScanRoot>> {
if !is_under_codex_dir(&root.path) && root.path.join(".codex").exists() {
return Err(io::Error::other(
"parent codex roots keep directory scan to preserve external IDs",
));
}
let sessions = codex_sessions_dir(&root.path);
if sessions == root.path
&& root
.path
.file_name()
.and_then(|name| name.to_str())
.is_none_or(|name| name != "sessions")
{
return Err(io::Error::other(
"roots without a sessions directory keep directory scan to preserve external IDs",
));
}
let files = collect_codex_rollout_files(&sessions, since_ts)?;
Ok(files
.into_iter()
.map(|path| {
let mut file_root = root.clone();
file_root.path = path;
file_root
})
.collect())
}
fn is_under_codex_dir(path: &Path) -> bool {
path.ancestors().any(|ancestor| {
ancestor
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == ".codex")
})
}
fn codex_sessions_dir(home: &Path) -> PathBuf {
let sessions = home.join("sessions");
if sessions.exists() {
sessions
} else {
home.to_path_buf()
}
}
fn collect_codex_rollout_files(sessions: &Path, since_ts: Option<i64>) -> io::Result<Vec<PathBuf>> {
if !sessions.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
let mut pending_dirs = vec![sessions.to_path_buf()];
while let Some(dir) = pending_dirs.pop() {
let mut entries = fs::read_dir(&dir)?.collect::<io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.path());
for entry in entries {
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_dir() {
pending_dirs.push(path);
} else if file_type.is_file()
&& is_codex_rollout_file(&path)
&& file_modified_since(&path, since_ts)
{
files.push(path);
}
}
}
files.sort();
files.dedup();
Ok(files)
}
fn is_codex_rollout_file(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.starts_with("rollout-")
&& path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
ext.eq_ignore_ascii_case("jsonl") || ext.eq_ignore_ascii_case("json")
})
}
pub mod aider;
pub mod amp;
pub mod chatgpt;
pub mod claude_code;
pub mod clawdbot;
pub mod cline;
pub mod codex;
pub mod copilot;
pub mod copilot_cli;
pub mod crush;
pub mod cursor;
pub mod factory;
pub mod gemini;
pub mod hermes;
pub mod kimi;
pub mod openclaw;
pub mod opencode;
pub mod pi_agent;
pub mod qwen;
pub mod vibe;