use anyhow::Result;
use std::path::PathBuf;
#[cfg(feature = "connectors")]
pub mod aider;
#[cfg(feature = "connectors")]
pub mod codex;
#[cfg(feature = "connectors")]
pub mod cursor;
#[cfg(feature = "connectors")]
pub mod opencode;
#[derive(Debug, Clone)]
pub enum ConnectorStatus {
Available {
path: PathBuf,
sessions_estimate: Option<usize>,
},
NotFound,
Error(String),
}
pub trait SessionConnector: Send + Sync {
fn source_id(&self) -> &str;
fn display_name(&self) -> &str;
fn detect(&self) -> ConnectorStatus;
fn default_path(&self) -> Option<PathBuf>;
fn import(&self, options: &ImportOptions) -> Result<Vec<NormalizedSession>>;
}
#[derive(Debug, Clone, Default)]
pub struct ImportOptions {
pub path: Option<PathBuf>,
pub since: Option<jiff::Timestamp>,
pub until: Option<jiff::Timestamp>,
pub limit: Option<usize>,
pub incremental: bool,
}
#[derive(Debug, Clone)]
pub struct NormalizedSession {
pub source: String,
pub external_id: String,
pub title: Option<String>,
pub source_path: PathBuf,
pub started_at: Option<jiff::Timestamp>,
pub ended_at: Option<jiff::Timestamp>,
pub messages: Vec<NormalizedMessage>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct NormalizedMessage {
pub idx: usize,
pub role: String,
pub author: Option<String>,
pub content: String,
pub created_at: Option<jiff::Timestamp>,
pub extra: serde_json::Value,
}
pub struct ConnectorRegistry {
connectors: Vec<Box<dyn SessionConnector>>,
}
impl ConnectorRegistry {
#[must_use]
pub fn new() -> Self {
#[allow(unused_mut)] let mut connectors: Vec<Box<dyn SessionConnector>> = vec![Box::new(ClaudeCodeConnector)];
#[cfg(feature = "connectors")]
{
connectors.push(Box::new(cursor::CursorConnector));
connectors.push(Box::new(codex::CodexConnector));
connectors.push(Box::new(aider::AiderConnector));
connectors.push(Box::new(opencode::OpenCodeConnector));
}
Self { connectors }
}
#[must_use]
pub fn connectors(&self) -> &[Box<dyn SessionConnector>] {
&self.connectors
}
#[must_use]
pub fn get(&self, source_id: &str) -> Option<&dyn SessionConnector> {
self.connectors
.iter()
.find(|c| c.source_id() == source_id)
.map(|c| c.as_ref())
}
pub fn detect_all(&self) -> Vec<(&str, ConnectorStatus)> {
self.connectors
.iter()
.map(|c| (c.source_id(), c.detect()))
.collect()
}
}
impl Default for ConnectorRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Default)]
pub struct ClaudeCodeConnector;
impl SessionConnector for ClaudeCodeConnector {
fn source_id(&self) -> &str {
"claude-code"
}
fn display_name(&self) -> &str {
"Claude Code"
}
fn detect(&self) -> ConnectorStatus {
if let Some(path) = self.default_path() {
if path.exists() {
let count = walkdir::WalkDir::new(&path)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
.count();
ConnectorStatus::Available {
path,
sessions_estimate: Some(count),
}
} else {
ConnectorStatus::NotFound
}
} else {
ConnectorStatus::NotFound
}
}
fn default_path(&self) -> Option<PathBuf> {
home::home_dir().map(|h| h.join(".claude").join("projects"))
}
fn import(&self, options: &ImportOptions) -> Result<Vec<NormalizedSession>> {
use crate::parser::SessionParser;
let path = options
.path
.clone()
.or_else(|| self.default_path())
.ok_or_else(|| anyhow::anyhow!("No path specified and default not found"))?;
let parsers = SessionParser::from_directory(&path)?;
let mut sessions = Vec::new();
for parser in parsers {
let entries = parser.entries();
if entries.is_empty() {
continue;
}
let first = entries.first().unwrap();
let last = entries.last().unwrap();
let messages: Vec<NormalizedMessage> = entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let (role, content) = match &entry.message {
crate::models::Message::User { content, .. } => {
("user".to_string(), content.clone())
}
crate::models::Message::Assistant { content, .. } => {
let text = content
.iter()
.filter_map(|block| match block {
crate::models::ContentBlock::Text { text } => {
Some(text.clone())
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
("assistant".to_string(), text)
}
crate::models::Message::ToolResult { content, .. } => {
let text = content
.iter()
.map(|c| c.content.clone())
.collect::<Vec<_>>()
.join("\n");
("tool".to_string(), text)
}
};
NormalizedMessage {
idx,
role,
author: None,
content,
created_at: crate::models::parse_timestamp(&entry.timestamp).ok(),
extra: serde_json::Value::Null,
}
})
.collect();
sessions.push(NormalizedSession {
source: "claude-code".to_string(),
external_id: first.session_id.clone(),
title: first.cwd.clone(),
source_path: path.clone(),
started_at: crate::models::parse_timestamp(&first.timestamp).ok(),
ended_at: crate::models::parse_timestamp(&last.timestamp).ok(),
messages,
metadata: serde_json::json!({
"project_path": first.cwd,
}),
});
}
Ok(sessions)
}
}