terraphim_session_analyzer/connectors/
mod.rs1use anyhow::Result;
13use std::path::PathBuf;
14
15#[cfg(feature = "connectors")]
16pub mod aider;
17#[cfg(feature = "connectors")]
18pub mod codex;
19#[cfg(feature = "connectors")]
20pub mod cursor;
21#[cfg(feature = "connectors")]
22pub mod opencode;
23
24#[derive(Debug, Clone)]
26pub enum ConnectorStatus {
27 Available {
29 path: PathBuf,
30 sessions_estimate: Option<usize>,
31 },
32 NotFound,
34 Error(String),
36}
37
38pub trait SessionConnector: Send + Sync {
40 fn source_id(&self) -> &str;
42
43 fn display_name(&self) -> &str;
45
46 fn detect(&self) -> ConnectorStatus;
48
49 fn default_path(&self) -> Option<PathBuf>;
51
52 fn import(&self, options: &ImportOptions) -> Result<Vec<NormalizedSession>>;
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct ImportOptions {
59 pub path: Option<PathBuf>,
61 pub since: Option<jiff::Timestamp>,
63 pub until: Option<jiff::Timestamp>,
65 pub limit: Option<usize>,
67 pub incremental: bool,
69}
70
71#[derive(Debug, Clone)]
73pub struct NormalizedSession {
74 pub source: String,
76 pub external_id: String,
78 pub title: Option<String>,
80 pub source_path: PathBuf,
82 pub started_at: Option<jiff::Timestamp>,
84 pub ended_at: Option<jiff::Timestamp>,
86 pub messages: Vec<NormalizedMessage>,
88 pub metadata: serde_json::Value,
90}
91
92#[derive(Debug, Clone)]
94pub struct NormalizedMessage {
95 pub idx: usize,
97 pub role: String,
99 pub author: Option<String>,
101 pub content: String,
103 pub created_at: Option<jiff::Timestamp>,
105 pub extra: serde_json::Value,
107}
108
109pub struct ConnectorRegistry {
111 connectors: Vec<Box<dyn SessionConnector>>,
112}
113
114impl ConnectorRegistry {
115 #[must_use]
117 pub fn new() -> Self {
118 #[allow(unused_mut)] let mut connectors: Vec<Box<dyn SessionConnector>> = vec![Box::new(ClaudeCodeConnector)];
121
122 #[cfg(feature = "connectors")]
124 {
125 connectors.push(Box::new(cursor::CursorConnector));
126 connectors.push(Box::new(codex::CodexConnector));
127 connectors.push(Box::new(aider::AiderConnector));
128 connectors.push(Box::new(opencode::OpenCodeConnector));
129 }
130
131 Self { connectors }
132 }
133
134 #[must_use]
136 pub fn connectors(&self) -> &[Box<dyn SessionConnector>] {
137 &self.connectors
138 }
139
140 #[must_use]
142 pub fn get(&self, source_id: &str) -> Option<&dyn SessionConnector> {
143 self.connectors
144 .iter()
145 .find(|c| c.source_id() == source_id)
146 .map(|c| c.as_ref())
147 }
148
149 pub fn detect_all(&self) -> Vec<(&str, ConnectorStatus)> {
151 self.connectors
152 .iter()
153 .map(|c| (c.source_id(), c.detect()))
154 .collect()
155 }
156}
157
158impl Default for ConnectorRegistry {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164#[derive(Debug, Default)]
166pub struct ClaudeCodeConnector;
167
168impl SessionConnector for ClaudeCodeConnector {
169 fn source_id(&self) -> &str {
170 "claude-code"
171 }
172
173 fn display_name(&self) -> &str {
174 "Claude Code"
175 }
176
177 fn detect(&self) -> ConnectorStatus {
178 if let Some(path) = self.default_path() {
179 if path.exists() {
180 let count = walkdir::WalkDir::new(&path)
182 .max_depth(3)
183 .into_iter()
184 .filter_map(|e| e.ok())
185 .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
186 .count();
187 ConnectorStatus::Available {
188 path,
189 sessions_estimate: Some(count),
190 }
191 } else {
192 ConnectorStatus::NotFound
193 }
194 } else {
195 ConnectorStatus::NotFound
196 }
197 }
198
199 fn default_path(&self) -> Option<PathBuf> {
200 home::home_dir().map(|h| h.join(".claude").join("projects"))
201 }
202
203 fn import(&self, options: &ImportOptions) -> Result<Vec<NormalizedSession>> {
204 use crate::parser::SessionParser;
205
206 let path = options
207 .path
208 .clone()
209 .or_else(|| self.default_path())
210 .ok_or_else(|| anyhow::anyhow!("No path specified and default not found"))?;
211
212 let parsers = SessionParser::from_directory(&path)?;
213 let mut sessions = Vec::new();
214
215 for parser in parsers {
216 let entries = parser.entries();
218 if entries.is_empty() {
219 continue;
220 }
221
222 let first = entries.first().unwrap();
223 let last = entries.last().unwrap();
224
225 let messages: Vec<NormalizedMessage> = entries
226 .iter()
227 .enumerate()
228 .map(|(idx, entry)| {
229 let (role, content) = match &entry.message {
230 crate::models::Message::User { content, .. } => {
231 ("user".to_string(), content.clone())
232 }
233 crate::models::Message::Assistant { content, .. } => {
234 let text = content
235 .iter()
236 .filter_map(|block| match block {
237 crate::models::ContentBlock::Text { text } => {
238 Some(text.clone())
239 }
240 _ => None,
241 })
242 .collect::<Vec<_>>()
243 .join("\n");
244 ("assistant".to_string(), text)
245 }
246 crate::models::Message::ToolResult { content, .. } => {
247 let text = content
248 .iter()
249 .map(|c| c.content.clone())
250 .collect::<Vec<_>>()
251 .join("\n");
252 ("tool".to_string(), text)
253 }
254 };
255
256 NormalizedMessage {
257 idx,
258 role,
259 author: None,
260 content,
261 created_at: crate::models::parse_timestamp(&entry.timestamp).ok(),
262 extra: serde_json::Value::Null,
263 }
264 })
265 .collect();
266
267 sessions.push(NormalizedSession {
268 source: "claude-code".to_string(),
269 external_id: first.session_id.clone(),
270 title: first.cwd.clone(),
271 source_path: path.clone(),
272 started_at: crate::models::parse_timestamp(&first.timestamp).ok(),
273 ended_at: crate::models::parse_timestamp(&last.timestamp).ok(),
274 messages,
275 metadata: serde_json::json!({
276 "project_path": first.cwd,
277 }),
278 });
279 }
280
281 Ok(sessions)
282 }
283}