1pub mod claude_code;
12
13use std::io;
14use std::path::{Path, PathBuf};
15
16use crate::config::Config;
17use crate::store::Event;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Kpi {
24 Tokens,
26 CacheTokens,
28 Cost,
30 Prompts,
32 ToolCalls,
34 StopReason,
36 DurationMs,
38 Sidechain,
40}
41
42impl Kpi {
43 pub const ALL: [Kpi; 8] = [
45 Kpi::Tokens,
46 Kpi::CacheTokens,
47 Kpi::Cost,
48 Kpi::Prompts,
49 Kpi::ToolCalls,
50 Kpi::StopReason,
51 Kpi::DurationMs,
52 Kpi::Sidechain,
53 ];
54
55 pub fn label(self) -> &'static str {
57 match self {
58 Kpi::Tokens => "tokens",
59 Kpi::CacheTokens => "cache",
60 Kpi::Cost => "cost",
61 Kpi::Prompts => "prompts",
62 Kpi::ToolCalls => "tools",
63 Kpi::StopReason => "stop_reason",
64 Kpi::DurationMs => "duration_ms",
65 Kpi::Sidechain => "sidechain",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct Capabilities {
73 supported: &'static [Kpi],
74}
75
76impl Capabilities {
77 pub const fn new(supported: &'static [Kpi]) -> Self {
78 Self { supported }
79 }
80
81 pub const fn none() -> Self {
83 Self::new(&[])
84 }
85
86 pub fn supports(&self, kpi: Kpi) -> bool {
87 self.supported.contains(&kpi)
88 }
89
90 pub fn supported(&self) -> &'static [Kpi] {
91 self.supported
92 }
93
94 pub fn unsupported(&self) -> Vec<Kpi> {
97 Kpi::ALL
98 .into_iter()
99 .filter(|kpi| !self.supports(*kpi))
100 .collect()
101 }
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct ParsedRecord {
107 pub event: Event,
108 pub prompt_text: Option<String>,
111 pub usage_key: Option<String>,
115}
116
117#[derive(Debug, Clone, PartialEq)]
119pub enum Parsed {
120 Record(Box<ParsedRecord>),
122 Skipped,
125 Unparseable,
129}
130
131pub trait Adapter {
133 fn name(&self) -> &'static str;
135
136 fn is_implemented(&self) -> bool;
138
139 fn capabilities(&self) -> Capabilities;
141
142 fn root(&self, config: &Config) -> Option<PathBuf>;
145
146 fn discover(&self, root: &Path) -> io::Result<Vec<PathBuf>>;
148
149 fn session_count(&self, root: &Path) -> io::Result<usize>;
151
152 fn parse_line(&self, source: &Path, line: &str) -> Parsed;
154}
155
156pub fn usage_key(agent: &str, session_id: Option<&str>, turn_id: &str) -> String {
162 format!("{agent}\u{1}{}\u{1}{turn_id}", session_id.unwrap_or(""))
163}
164
165pub fn registry() -> Vec<Box<dyn Adapter>> {
168 vec![
169 Box::new(claude_code::ClaudeCodeAdapter),
170 Box::new(NotImplementedAdapter {
171 name: "codex",
172 default_root: "~/.codex/sessions",
173 }),
174 Box::new(NotImplementedAdapter {
175 name: "cursor",
176 default_root: "",
177 }),
178 ]
179}
180
181pub fn enabled(config: &Config) -> Vec<Box<dyn Adapter>> {
183 registry()
184 .into_iter()
185 .filter(|adapter| adapter.is_implemented() && config.source(adapter.name()).enabled)
186 .collect()
187}
188
189struct NotImplementedAdapter {
192 name: &'static str,
193 default_root: &'static str,
196}
197
198impl Adapter for NotImplementedAdapter {
199 fn name(&self) -> &'static str {
200 self.name
201 }
202
203 fn is_implemented(&self) -> bool {
204 false
205 }
206
207 fn capabilities(&self) -> Capabilities {
208 Capabilities::none()
209 }
210
211 fn root(&self, config: &Config) -> Option<PathBuf> {
212 config
213 .source(self.name)
214 .path
215 .or_else(|| (!self.default_root.is_empty()).then(|| PathBuf::from(self.default_root)))
216 }
217
218 fn discover(&self, _root: &Path) -> io::Result<Vec<PathBuf>> {
219 Ok(Vec::new())
220 }
221
222 fn session_count(&self, _root: &Path) -> io::Result<usize> {
223 Ok(0)
224 }
225
226 fn parse_line(&self, _source: &Path, _line: &str) -> Parsed {
227 Parsed::Skipped
228 }
229}
230
231pub(crate) fn jsonl_files(root: &Path) -> io::Result<Vec<PathBuf>> {
234 let mut found = Vec::new();
235 let mut stack = vec![root.to_path_buf()];
236 while let Some(dir) = stack.pop() {
237 let entries = match std::fs::read_dir(&dir) {
238 Ok(entries) => entries,
239 Err(_) => continue,
240 };
241 for entry in entries.flatten() {
242 let path = entry.path();
243 match entry.file_type() {
244 Ok(kind) if kind.is_dir() => stack.push(path),
245 Ok(_) if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") => {
246 found.push(path)
247 }
248 _ => {}
249 }
250 }
251 }
252 found.sort();
253 Ok(found)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn registry_lists_every_adapter_a_user_might_expect() {
262 let names: Vec<_> = registry().iter().map(|a| a.name()).collect();
263 assert_eq!(names, ["claude-code", "codex", "cursor"]);
264 }
265
266 #[test]
267 fn unimplemented_adapters_declare_no_kpis() {
268 for adapter in registry().iter().filter(|a| !a.is_implemented()) {
269 assert!(adapter.capabilities().supported().is_empty());
270 assert_eq!(adapter.capabilities().unsupported().len(), Kpi::ALL.len());
271 }
272 }
273
274 #[test]
275 fn usage_key_boundaries_are_unambiguous() {
276 assert_ne!(
277 usage_key("a", Some("b"), "c"),
278 usage_key("a", Some("bc"), "")
279 );
280 assert_eq!(usage_key("a", None, "c"), usage_key("a", Some(""), "c"));
281 }
282
283 #[test]
284 fn jsonl_walk_is_recursive_sorted_and_extension_filtered() {
285 let dir = tempfile::tempdir().unwrap();
286 let nested = dir.path().join("proj/deeper");
287 std::fs::create_dir_all(&nested).unwrap();
288 std::fs::write(dir.path().join("b.jsonl"), "").unwrap();
289 std::fs::write(dir.path().join("a.txt"), "").unwrap();
290 std::fs::write(nested.join("a.jsonl"), "").unwrap();
291
292 let found = jsonl_files(dir.path()).unwrap();
293 assert_eq!(
294 found,
295 vec![dir.path().join("b.jsonl"), nested.join("a.jsonl")]
296 );
297 }
298}