#[cfg(feature = "format-claude")]
mod claude_code;
#[cfg(feature = "format-codex")]
mod codex;
#[cfg(feature = "format-gemini")]
mod gemini_cli;
#[cfg(feature = "format-normalize")]
mod normalize_agent;
#[cfg(feature = "format-claude")]
pub use claude_code::ClaudeCodeFormat;
#[cfg(feature = "format-codex")]
pub use codex::CodexFormat;
#[cfg(feature = "format-gemini")]
pub use gemini_cli::GeminiCliFormat;
#[cfg(feature = "format-normalize")]
pub use normalize_agent::NormalizeAgentFormat;
use crate::Session;
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("I/O error reading {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parse error in {path}: {message}")]
Format { path: PathBuf, message: String },
#[error("{0}")]
Other(String),
}
static FORMATS: RwLock<Vec<&'static dyn LogFormat>> = RwLock::new(Vec::new());
static INITIALIZED: OnceLock<()> = OnceLock::new();
pub fn register(format: &'static dyn LogFormat) {
FORMATS.write().unwrap().push(format);
}
fn init_builtin() {
INITIALIZED.get_or_init(|| {
let mut formats = FORMATS.write().unwrap();
#[cfg(feature = "format-claude")]
formats.push(&ClaudeCodeFormat);
#[cfg(feature = "format-codex")]
formats.push(&CodexFormat);
#[cfg(feature = "format-gemini")]
formats.push(&GeminiCliFormat);
#[cfg(feature = "format-normalize")]
formats.push(&NormalizeAgentFormat);
});
}
pub struct SessionFile {
pub path: PathBuf,
pub mtime: std::time::SystemTime,
pub parent_id: Option<String>,
pub agent_id: Option<String>,
pub subagent_type: Option<String>,
}
pub trait LogFormat: Send + Sync {
fn name(&self) -> &'static str;
fn sessions_dir(&self, project: Option<&Path>) -> PathBuf;
fn list_sessions(&self, project: Option<&Path>) -> Vec<SessionFile>;
fn list_subagent_sessions(&self, _project: Option<&Path>) -> Vec<SessionFile> {
Vec::new()
}
fn metadata_roots(&self, project: Option<&Path>) -> Vec<PathBuf> {
vec![self.sessions_dir(project)]
}
fn detect(&self, path: &Path) -> f64;
fn parse(&self, path: &Path) -> Result<Session, ParseError>;
}
pub fn get_format(name: &str) -> Option<&'static dyn LogFormat> {
init_builtin();
FORMATS
.read()
.unwrap()
.iter()
.find(|f| f.name() == name)
.copied()
}
pub fn detect_format(path: &Path) -> Option<&'static dyn LogFormat> {
init_builtin();
let formats = FORMATS.read().unwrap();
let mut best: Option<(&'static dyn LogFormat, f64)> = None;
for fmt in formats.iter() {
let score = fmt.detect(path);
if score > 0.0 && best.is_none_or(|(_, best_score)| score > best_score) {
best = Some((*fmt, score));
}
}
best.map(|(fmt, _)| fmt)
}
pub fn list_formats() -> Vec<&'static str> {
init_builtin();
FORMATS.read().unwrap().iter().map(|f| f.name()).collect()
}
pub fn project_metadata_roots(project: &Path) -> Vec<PathBuf> {
init_builtin();
FORMATS
.read()
.unwrap()
.iter()
.flat_map(|f| f.metadata_roots(Some(project)))
.filter(|p| p.exists())
.collect()
}
pub fn list_jsonl_sessions(dir: &Path) -> Vec<SessionFile> {
let mut sessions = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl")
&& let Ok(meta) = path.metadata()
&& let Ok(mtime) = meta.modified()
{
sessions.push(SessionFile {
path,
mtime,
parent_id: None,
agent_id: None,
subagent_type: Some("interactive".into()),
});
}
}
}
sessions
}
pub fn list_subagent_sessions(dir: &Path) -> Vec<SessionFile> {
let mut sessions = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return sessions;
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let parent_id = path.file_name().and_then(|n| n.to_str()).map(String::from);
let subagents_dir = path.join("subagents");
if !subagents_dir.is_dir() {
continue;
}
let Ok(sub_entries) = std::fs::read_dir(&subagents_dir) else {
continue;
};
for sub_entry in sub_entries.filter_map(|e| e.ok()) {
let sub_path = sub_entry.path();
if sub_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let stem = match sub_path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
if !stem.starts_with("agent-") {
continue;
}
let Ok(meta) = sub_path.metadata() else {
continue;
};
let Ok(mtime) = meta.modified() else {
continue;
};
let meta_path = sub_path.with_extension("meta.json");
let subagent_type = Some(
std::fs::read_to_string(&meta_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| {
v.get("agentType")
.and_then(|t| t.as_str())
.map(String::from)
})
.unwrap_or_else(|| "subagent".into()),
);
sessions.push(SessionFile {
path: sub_path,
mtime,
parent_id: parent_id.clone(),
agent_id: Some(stem.clone()),
subagent_type,
});
}
}
sessions
}
pub struct FormatRegistry {
formats: Vec<Box<dyn LogFormat>>,
}
impl Default for FormatRegistry {
fn default() -> Self {
Self::new()
}
}
impl FormatRegistry {
#[allow(clippy::vec_init_then_push)] pub fn new() -> Self {
let mut formats: Vec<Box<dyn LogFormat>> = Vec::new();
#[cfg(feature = "format-claude")]
formats.push(Box::new(ClaudeCodeFormat));
#[cfg(feature = "format-codex")]
formats.push(Box::new(CodexFormat));
#[cfg(feature = "format-gemini")]
formats.push(Box::new(GeminiCliFormat));
#[cfg(feature = "format-normalize")]
formats.push(Box::new(NormalizeAgentFormat));
Self { formats }
}
pub fn empty() -> Self {
Self { formats: vec![] }
}
pub fn register(&mut self, format: Box<dyn LogFormat>) {
self.formats.push(format);
}
pub fn detect(&self, path: &Path) -> Option<&dyn LogFormat> {
let mut best: Option<(&dyn LogFormat, f64)> = None;
for fmt in &self.formats {
let score = fmt.detect(path);
if score > 0.0 && best.is_none_or(|(_, best_score)| score > best_score) {
best = Some((fmt.as_ref(), score));
}
}
best.map(|(fmt, _)| fmt)
}
pub fn get(&self, name: &str) -> Option<&dyn LogFormat> {
self.formats
.iter()
.find(|f| f.name() == name)
.map(|f| f.as_ref())
}
pub fn list(&self) -> Vec<&'static str> {
self.formats.iter().map(|f| f.name()).collect()
}
pub fn list_subagent_sessions(&self, project: Option<&Path>) -> Vec<SessionFile> {
let mut all = Vec::new();
for fmt in &self.formats {
all.extend(fmt.list_subagent_sessions(project));
}
all
}
}
pub fn parse_session(path: &Path) -> Result<Session, ParseError> {
let registry = FormatRegistry::new();
let format = registry
.detect(path)
.ok_or_else(|| ParseError::Other(format!("Unknown log format: {}", path.display())))?;
format.parse(path)
}
pub fn parse_session_with_format(path: &Path, format_name: &str) -> Result<Session, ParseError> {
let registry = FormatRegistry::new();
let format = registry
.get(format_name)
.ok_or_else(|| ParseError::Other(format!("Unknown format: {}", format_name)))?;
format.parse(path)
}
pub(crate) fn peek_lines(path: &Path, n: usize) -> Vec<String> {
let Ok(file) = File::open(path) else {
return Vec::new();
};
BufReader::new(file)
.lines()
.take(n)
.filter_map(|l| l.ok())
.collect()
}
pub(crate) fn read_file(path: &Path) -> Result<String, ParseError> {
let mut file = File::open(path).map_err(|e| ParseError::Io {
path: path.to_path_buf(),
source: e,
})?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|e| ParseError::Io {
path: path.to_path_buf(),
source: e,
})?;
Ok(content)
}