normalize_chat_sessions/formats/
mod.rs1#[cfg(feature = "format-claude")]
27mod claude_code;
28#[cfg(feature = "format-codex")]
29mod codex;
30#[cfg(feature = "format-gemini")]
31mod gemini_cli;
32#[cfg(feature = "format-normalize")]
33mod normalize_agent;
34
35#[cfg(feature = "format-claude")]
36pub use claude_code::ClaudeCodeFormat;
37#[cfg(feature = "format-codex")]
38pub use codex::CodexFormat;
39#[cfg(feature = "format-gemini")]
40pub use gemini_cli::GeminiCliFormat;
41#[cfg(feature = "format-normalize")]
42pub use normalize_agent::NormalizeAgentFormat;
43
44use crate::Session;
45use std::fs::File;
46use std::io::{BufRead, BufReader, Read};
47use std::path::{Path, PathBuf};
48use std::sync::{OnceLock, RwLock};
49
50#[derive(Debug, thiserror::Error)]
52pub enum ParseError {
53 #[error("I/O error reading {path}: {source}")]
55 Io {
56 path: PathBuf,
57 #[source]
58 source: std::io::Error,
59 },
60 #[error("parse error in {path}: {message}")]
62 Format { path: PathBuf, message: String },
63 #[error("{0}")]
65 Other(String),
66}
67
68static FORMATS: RwLock<Vec<&'static dyn LogFormat>> = RwLock::new(Vec::new());
70static INITIALIZED: OnceLock<()> = OnceLock::new();
71
72pub fn register(format: &'static dyn LogFormat) {
77 FORMATS.write().unwrap().push(format);
79}
80
81fn init_builtin() {
83 INITIALIZED.get_or_init(|| {
84 let mut formats = FORMATS.write().unwrap();
86 #[cfg(feature = "format-claude")]
87 formats.push(&ClaudeCodeFormat);
88 #[cfg(feature = "format-codex")]
89 formats.push(&CodexFormat);
90 #[cfg(feature = "format-gemini")]
91 formats.push(&GeminiCliFormat);
92 #[cfg(feature = "format-normalize")]
93 formats.push(&NormalizeAgentFormat);
94 });
95}
96
97pub struct SessionFile {
99 pub path: PathBuf,
100 pub mtime: std::time::SystemTime,
101 pub parent_id: Option<String>,
103 pub agent_id: Option<String>,
105 pub subagent_type: Option<String>,
107}
108
109pub trait LogFormat: Send + Sync {
111 fn name(&self) -> &'static str;
113
114 fn sessions_dir(&self, project: Option<&Path>) -> PathBuf;
117
118 fn list_sessions(&self, project: Option<&Path>) -> Vec<SessionFile>;
120
121 fn list_subagent_sessions(&self, _project: Option<&Path>) -> Vec<SessionFile> {
124 Vec::new()
125 }
126
127 fn metadata_roots(&self, project: Option<&Path>) -> Vec<PathBuf> {
134 vec![self.sessions_dir(project)]
135 }
136
137 fn detect(&self, path: &Path) -> f64;
140
141 fn parse(&self, path: &Path) -> Result<Session, ParseError>;
143}
144
145pub fn get_format(name: &str) -> Option<&'static dyn LogFormat> {
147 init_builtin();
148 FORMATS
150 .read()
151 .unwrap()
152 .iter()
153 .find(|f| f.name() == name)
154 .copied()
155}
156
157pub fn detect_format(path: &Path) -> Option<&'static dyn LogFormat> {
159 init_builtin();
160 let formats = FORMATS.read().unwrap();
162 let mut best: Option<(&'static dyn LogFormat, f64)> = None;
163 for fmt in formats.iter() {
164 let score = fmt.detect(path);
165 if score > 0.0 && best.is_none_or(|(_, best_score)| score > best_score) {
166 best = Some((*fmt, score));
167 }
168 }
169 best.map(|(fmt, _)| fmt)
170}
171
172pub fn list_formats() -> Vec<&'static str> {
174 init_builtin();
175 FORMATS.read().unwrap().iter().map(|f| f.name()).collect()
177}
178
179pub fn project_metadata_roots(project: &Path) -> Vec<PathBuf> {
184 init_builtin();
185 FORMATS
187 .read()
188 .unwrap()
189 .iter()
190 .flat_map(|f| f.metadata_roots(Some(project)))
191 .filter(|p| p.exists())
192 .collect()
193}
194
195pub fn list_jsonl_sessions(dir: &Path) -> Vec<SessionFile> {
197 let mut sessions = Vec::new();
198 if let Ok(entries) = std::fs::read_dir(dir) {
199 for entry in entries.filter_map(|e| e.ok()) {
200 let path = entry.path();
201 if path.extension().and_then(|e| e.to_str()) == Some("jsonl")
202 && let Ok(meta) = path.metadata()
203 && let Ok(mtime) = meta.modified()
204 {
205 sessions.push(SessionFile {
206 path,
207 mtime,
208 parent_id: None,
209 agent_id: None,
210 subagent_type: Some("interactive".into()),
211 });
212 }
213 }
214 }
215 sessions
216}
217
218pub fn list_subagent_sessions(dir: &Path) -> Vec<SessionFile> {
223 let mut sessions = Vec::new();
224 let Ok(entries) = std::fs::read_dir(dir) else {
225 return sessions;
226 };
227 for entry in entries.filter_map(|e| e.ok()) {
228 let path = entry.path();
229 if !path.is_dir() {
230 continue;
231 }
232 let parent_id = path.file_name().and_then(|n| n.to_str()).map(String::from);
233 let subagents_dir = path.join("subagents");
234 if !subagents_dir.is_dir() {
235 continue;
236 }
237 let Ok(sub_entries) = std::fs::read_dir(&subagents_dir) else {
238 continue;
239 };
240 for sub_entry in sub_entries.filter_map(|e| e.ok()) {
241 let sub_path = sub_entry.path();
242 if sub_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
243 continue;
244 }
245 let stem = match sub_path.file_stem().and_then(|s| s.to_str()) {
246 Some(s) => s.to_string(),
247 None => continue,
248 };
249 if !stem.starts_with("agent-") {
250 continue;
251 }
252 let Ok(meta) = sub_path.metadata() else {
253 continue;
254 };
255 let Ok(mtime) = meta.modified() else {
256 continue;
257 };
258 let meta_path = sub_path.with_extension("meta.json");
260 let subagent_type = Some(
261 std::fs::read_to_string(&meta_path)
262 .ok()
263 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
264 .and_then(|v| {
265 v.get("agentType")
266 .and_then(|t| t.as_str())
267 .map(String::from)
268 })
269 .unwrap_or_else(|| "subagent".into()),
270 );
271 sessions.push(SessionFile {
272 path: sub_path,
273 mtime,
274 parent_id: parent_id.clone(),
275 agent_id: Some(stem.clone()),
276 subagent_type,
277 });
278 }
279 }
280 sessions
281}
282
283pub struct FormatRegistry {
290 formats: Vec<Box<dyn LogFormat>>,
291}
292
293impl Default for FormatRegistry {
294 fn default() -> Self {
295 Self::new()
296 }
297}
298
299impl FormatRegistry {
300 #[allow(clippy::vec_init_then_push)] pub fn new() -> Self {
303 let mut formats: Vec<Box<dyn LogFormat>> = Vec::new();
304 #[cfg(feature = "format-claude")]
305 formats.push(Box::new(ClaudeCodeFormat));
306 #[cfg(feature = "format-codex")]
307 formats.push(Box::new(CodexFormat));
308 #[cfg(feature = "format-gemini")]
309 formats.push(Box::new(GeminiCliFormat));
310 #[cfg(feature = "format-normalize")]
311 formats.push(Box::new(NormalizeAgentFormat));
312 Self { formats }
313 }
314
315 pub fn empty() -> Self {
317 Self { formats: vec![] }
318 }
319
320 pub fn register(&mut self, format: Box<dyn LogFormat>) {
322 self.formats.push(format);
323 }
324
325 pub fn detect(&self, path: &Path) -> Option<&dyn LogFormat> {
327 let mut best: Option<(&dyn LogFormat, f64)> = None;
328 for fmt in &self.formats {
329 let score = fmt.detect(path);
330 if score > 0.0 && best.is_none_or(|(_, best_score)| score > best_score) {
331 best = Some((fmt.as_ref(), score));
332 }
333 }
334 best.map(|(fmt, _)| fmt)
335 }
336
337 pub fn get(&self, name: &str) -> Option<&dyn LogFormat> {
339 self.formats
340 .iter()
341 .find(|f| f.name() == name)
342 .map(|f| f.as_ref())
343 }
344
345 pub fn list(&self) -> Vec<&'static str> {
347 self.formats.iter().map(|f| f.name()).collect()
348 }
349
350 pub fn list_subagent_sessions(&self, project: Option<&Path>) -> Vec<SessionFile> {
352 let mut all = Vec::new();
353 for fmt in &self.formats {
354 all.extend(fmt.list_subagent_sessions(project));
355 }
356 all
357 }
358}
359
360pub fn parse_session(path: &Path) -> Result<Session, ParseError> {
362 let registry = FormatRegistry::new();
363 let format = registry
364 .detect(path)
365 .ok_or_else(|| ParseError::Other(format!("Unknown log format: {}", path.display())))?;
366 format.parse(path)
367}
368
369pub fn parse_session_with_format(path: &Path, format_name: &str) -> Result<Session, ParseError> {
371 let registry = FormatRegistry::new();
372 let format = registry
373 .get(format_name)
374 .ok_or_else(|| ParseError::Other(format!("Unknown format: {}", format_name)))?;
375 format.parse(path)
376}
377
378pub(crate) fn peek_lines(path: &Path, n: usize) -> Vec<String> {
380 let Ok(file) = File::open(path) else {
381 return Vec::new();
382 };
383 BufReader::new(file)
384 .lines()
385 .take(n)
386 .filter_map(|l| l.ok())
387 .collect()
388}
389
390pub(crate) fn read_file(path: &Path) -> Result<String, ParseError> {
392 let mut file = File::open(path).map_err(|e| ParseError::Io {
393 path: path.to_path_buf(),
394 source: e,
395 })?;
396 let mut content = String::new();
397 file.read_to_string(&mut content)
398 .map_err(|e| ParseError::Io {
399 path: path.to_path_buf(),
400 source: e,
401 })?;
402 Ok(content)
403}