pub mod claude_code;
use std::io;
use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::store::Event;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Kpi {
Tokens,
CacheTokens,
Cost,
Prompts,
ToolCalls,
StopReason,
DurationMs,
Sidechain,
}
impl Kpi {
pub const ALL: [Kpi; 8] = [
Kpi::Tokens,
Kpi::CacheTokens,
Kpi::Cost,
Kpi::Prompts,
Kpi::ToolCalls,
Kpi::StopReason,
Kpi::DurationMs,
Kpi::Sidechain,
];
pub fn label(self) -> &'static str {
match self {
Kpi::Tokens => "tokens",
Kpi::CacheTokens => "cache",
Kpi::Cost => "cost",
Kpi::Prompts => "prompts",
Kpi::ToolCalls => "tools",
Kpi::StopReason => "stop_reason",
Kpi::DurationMs => "duration_ms",
Kpi::Sidechain => "sidechain",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
supported: &'static [Kpi],
}
impl Capabilities {
pub const fn new(supported: &'static [Kpi]) -> Self {
Self { supported }
}
pub const fn none() -> Self {
Self::new(&[])
}
pub fn supports(&self, kpi: Kpi) -> bool {
self.supported.contains(&kpi)
}
pub fn supported(&self) -> &'static [Kpi] {
self.supported
}
pub fn unsupported(&self) -> Vec<Kpi> {
Kpi::ALL
.into_iter()
.filter(|kpi| !self.supports(*kpi))
.collect()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedRecord {
pub event: Event,
pub prompt_text: Option<String>,
pub usage_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Parsed {
Record(Box<ParsedRecord>),
Skipped,
Unparseable,
}
pub trait Adapter {
fn name(&self) -> &'static str;
fn is_implemented(&self) -> bool;
fn capabilities(&self) -> Capabilities;
fn root(&self, config: &Config) -> Option<PathBuf>;
fn discover(&self, root: &Path) -> io::Result<Vec<PathBuf>>;
fn session_count(&self, root: &Path) -> io::Result<usize>;
fn parse_line(&self, source: &Path, line: &str) -> Parsed;
}
pub fn usage_key(agent: &str, session_id: Option<&str>, turn_id: &str) -> String {
format!("{agent}\u{1}{}\u{1}{turn_id}", session_id.unwrap_or(""))
}
pub fn registry() -> Vec<Box<dyn Adapter>> {
vec![
Box::new(claude_code::ClaudeCodeAdapter),
Box::new(NotImplementedAdapter {
name: "codex",
default_root: "~/.codex/sessions",
}),
Box::new(NotImplementedAdapter {
name: "cursor",
default_root: "",
}),
]
}
pub fn enabled(config: &Config) -> Vec<Box<dyn Adapter>> {
registry()
.into_iter()
.filter(|adapter| adapter.is_implemented() && config.source(adapter.name()).enabled)
.collect()
}
struct NotImplementedAdapter {
name: &'static str,
default_root: &'static str,
}
impl Adapter for NotImplementedAdapter {
fn name(&self) -> &'static str {
self.name
}
fn is_implemented(&self) -> bool {
false
}
fn capabilities(&self) -> Capabilities {
Capabilities::none()
}
fn root(&self, config: &Config) -> Option<PathBuf> {
config
.source(self.name)
.path
.or_else(|| (!self.default_root.is_empty()).then(|| PathBuf::from(self.default_root)))
}
fn discover(&self, _root: &Path) -> io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
fn session_count(&self, _root: &Path) -> io::Result<usize> {
Ok(0)
}
fn parse_line(&self, _source: &Path, _line: &str) -> Parsed {
Parsed::Skipped
}
}
pub(crate) fn jsonl_files(root: &Path) -> io::Result<Vec<PathBuf>> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(kind) if kind.is_dir() => stack.push(path),
Ok(_) if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") => {
found.push(path)
}
_ => {}
}
}
}
found.sort();
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_lists_every_adapter_a_user_might_expect() {
let names: Vec<_> = registry().iter().map(|a| a.name()).collect();
assert_eq!(names, ["claude-code", "codex", "cursor"]);
}
#[test]
fn unimplemented_adapters_declare_no_kpis() {
for adapter in registry().iter().filter(|a| !a.is_implemented()) {
assert!(adapter.capabilities().supported().is_empty());
assert_eq!(adapter.capabilities().unsupported().len(), Kpi::ALL.len());
}
}
#[test]
fn usage_key_boundaries_are_unambiguous() {
assert_ne!(
usage_key("a", Some("b"), "c"),
usage_key("a", Some("bc"), "")
);
assert_eq!(usage_key("a", None, "c"), usage_key("a", Some(""), "c"));
}
#[test]
fn jsonl_walk_is_recursive_sorted_and_extension_filtered() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("proj/deeper");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(dir.path().join("b.jsonl"), "").unwrap();
std::fs::write(dir.path().join("a.txt"), "").unwrap();
std::fs::write(nested.join("a.jsonl"), "").unwrap();
let found = jsonl_files(dir.path()).unwrap();
assert_eq!(
found,
vec![dir.path().join("b.jsonl"), nested.join("a.jsonl")]
);
}
}