#[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};
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 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 detect(&self, path: &Path) -> f64;
fn parse(&self, path: &Path) -> Result<Session, String>;
}
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() || score > best.unwrap().1) {
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 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") {
if let Ok(meta) = path.metadata() {
if let Ok(mtime) = meta.modified() {
sessions.push(SessionFile { path, mtime });
}
}
}
}
}
sessions
}
pub struct FormatRegistry {
formats: Vec<Box<dyn LogFormat>>,
}
impl Default for FormatRegistry {
fn default() -> Self {
Self::new()
}
}
impl FormatRegistry {
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 {
if best.is_none() || score > best.unwrap().1 {
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 parse_session(path: &Path) -> Result<Session, String> {
let registry = FormatRegistry::new();
let format = registry
.detect(path)
.ok_or_else(|| format!("Unknown log format: {}", path.display()))?;
format.parse(path)
}
pub fn parse_session_with_format(path: &Path, format_name: &str) -> Result<Session, String> {
let registry = FormatRegistry::new();
let format = registry
.get(format_name)
.ok_or_else(|| 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, String> {
let mut file = File::open(path).map_err(|e| e.to_string())?;
let mut content = String::new();
file.read_to_string(&mut content)
.map_err(|e| e.to_string())?;
Ok(content)
}