1#![doc = include_str!("../README.md")]
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8#[derive(Debug, thiserror::Error)]
12pub enum ConvoError {
13 #[error("I/O error: {0}")]
14 Io(#[from] std::io::Error),
15
16 #[error("JSON error: {0}")]
17 Json(#[from] serde_json::Error),
18
19 #[error("provider error: {0}")]
20 Provider(String),
21
22 #[error("{0}")]
23 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
24}
25
26pub type Result<T> = std::result::Result<T, ConvoError>;
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum Role {
33 User,
34 Assistant,
35 System,
36 Other(String),
38}
39
40impl std::fmt::Display for Role {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Role::User => write!(f, "user"),
44 Role::Assistant => write!(f, "assistant"),
45 Role::System => write!(f, "system"),
46 Role::Other(s) => write!(f, "{}", s),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct TokenUsage {
54 pub input_tokens: Option<u32>,
55 pub output_tokens: Option<u32>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ToolInvocation {
61 pub id: String,
62 pub name: String,
63 pub input: serde_json::Value,
64 pub result: Option<ToolResult>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ToolResult {
71 pub content: String,
72 pub is_error: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Turn {
78 pub id: String,
80
81 pub parent_id: Option<String>,
83
84 pub role: Role,
86
87 pub timestamp: String,
89
90 pub text: String,
92
93 pub thinking: Option<String>,
95
96 pub tool_uses: Vec<ToolInvocation>,
98
99 pub model: Option<String>,
101
102 pub stop_reason: Option<String>,
104
105 pub token_usage: Option<TokenUsage>,
107
108 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
110 pub extra: HashMap<String, serde_json::Value>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ConversationView {
116 pub id: String,
118
119 pub started_at: Option<DateTime<Utc>>,
121
122 pub last_activity: Option<DateTime<Utc>>,
124
125 pub turns: Vec<Turn>,
127}
128
129impl ConversationView {
130 pub fn title(&self, max_len: usize) -> Option<String> {
132 let text = self
133 .turns
134 .iter()
135 .find(|t| t.role == Role::User && !t.text.is_empty())
136 .map(|t| &t.text)?;
137
138 if text.chars().count() > max_len {
139 let truncated: String = text.chars().take(max_len).collect();
140 Some(format!("{}...", truncated))
141 } else {
142 Some(text.clone())
143 }
144 }
145
146 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
148 self.turns.iter().filter(|t| &t.role == role).collect()
149 }
150
151 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
156 match self.turns.iter().position(|t| t.id == turn_id) {
157 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
158 Some(_) => &[],
159 None => &self.turns,
160 }
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ConversationMeta {
167 pub id: String,
168 pub started_at: Option<DateTime<Utc>>,
169 pub last_activity: Option<DateTime<Utc>>,
170 pub message_count: usize,
171 pub file_path: Option<PathBuf>,
172}
173
174#[derive(Debug, Clone)]
178pub enum WatcherEvent {
179 Turn(Box<Turn>),
181
182 TurnUpdated(Box<Turn>),
188
189 Progress {
191 kind: String,
192 data: serde_json::Value,
193 },
194}
195
196pub trait ConversationProvider {
203 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
205
206 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
208
209 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
211
212 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
214}
215
216pub trait ConversationWatcher {
218 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
220
221 fn seen_count(&self) -> usize;
223}
224
225#[cfg(test)]
228mod tests {
229 use super::*;
230
231 fn sample_view() -> ConversationView {
232 ConversationView {
233 id: "sess-1".into(),
234 started_at: None,
235 last_activity: None,
236 turns: vec![
237 Turn {
238 id: "t1".into(),
239 parent_id: None,
240 role: Role::User,
241 timestamp: "2026-01-01T00:00:00Z".into(),
242 text: "Fix the authentication bug in login.rs".into(),
243 thinking: None,
244 tool_uses: vec![],
245 model: None,
246 stop_reason: None,
247 token_usage: None,
248 extra: HashMap::new(),
249 },
250 Turn {
251 id: "t2".into(),
252 parent_id: Some("t1".into()),
253 role: Role::Assistant,
254 timestamp: "2026-01-01T00:00:01Z".into(),
255 text: "I'll fix that for you.".into(),
256 thinking: Some("The bug is in the token validation".into()),
257 tool_uses: vec![ToolInvocation {
258 id: "tool-1".into(),
259 name: "Read".into(),
260 input: serde_json::json!({"file": "src/login.rs"}),
261 result: Some(ToolResult {
262 content: "fn login() { ... }".into(),
263 is_error: false,
264 }),
265 }],
266 model: Some("claude-opus-4-6".into()),
267 stop_reason: Some("end_turn".into()),
268 token_usage: Some(TokenUsage {
269 input_tokens: Some(100),
270 output_tokens: Some(50),
271 }),
272 extra: HashMap::new(),
273 },
274 Turn {
275 id: "t3".into(),
276 parent_id: Some("t2".into()),
277 role: Role::User,
278 timestamp: "2026-01-01T00:00:02Z".into(),
279 text: "Thanks!".into(),
280 thinking: None,
281 tool_uses: vec![],
282 model: None,
283 stop_reason: None,
284 token_usage: None,
285 extra: HashMap::new(),
286 },
287 ],
288 }
289 }
290
291 #[test]
292 fn test_title_short() {
293 let view = sample_view();
294 let title = view.title(100).unwrap();
295 assert_eq!(title, "Fix the authentication bug in login.rs");
296 }
297
298 #[test]
299 fn test_title_truncated() {
300 let view = sample_view();
301 let title = view.title(10).unwrap();
302 assert_eq!(title, "Fix the au...");
303 }
304
305 #[test]
306 fn test_title_empty() {
307 let view = ConversationView {
308 id: "empty".into(),
309 started_at: None,
310 last_activity: None,
311 turns: vec![],
312 };
313 assert!(view.title(50).is_none());
314 }
315
316 #[test]
317 fn test_turns_by_role() {
318 let view = sample_view();
319 let users = view.turns_by_role(&Role::User);
320 assert_eq!(users.len(), 2);
321 let assistants = view.turns_by_role(&Role::Assistant);
322 assert_eq!(assistants.len(), 1);
323 }
324
325 #[test]
326 fn test_turns_since_middle() {
327 let view = sample_view();
328 let since = view.turns_since("t1");
329 assert_eq!(since.len(), 2);
330 assert_eq!(since[0].id, "t2");
331 }
332
333 #[test]
334 fn test_turns_since_last() {
335 let view = sample_view();
336 let since = view.turns_since("t3");
337 assert!(since.is_empty());
338 }
339
340 #[test]
341 fn test_turns_since_unknown() {
342 let view = sample_view();
343 let since = view.turns_since("nonexistent");
344 assert_eq!(since.len(), 3);
345 }
346
347 #[test]
348 fn test_role_display() {
349 assert_eq!(Role::User.to_string(), "user");
350 assert_eq!(Role::Assistant.to_string(), "assistant");
351 assert_eq!(Role::System.to_string(), "system");
352 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
353 }
354
355 #[test]
356 fn test_role_equality() {
357 assert_eq!(Role::User, Role::User);
358 assert_ne!(Role::User, Role::Assistant);
359 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
360 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
361 }
362
363 #[test]
364 fn test_turn_serde_roundtrip() {
365 let turn = &sample_view().turns[1];
366 let json = serde_json::to_string(turn).unwrap();
367 let back: Turn = serde_json::from_str(&json).unwrap();
368 assert_eq!(back.id, "t2");
369 assert_eq!(back.model, Some("claude-opus-4-6".into()));
370 assert_eq!(back.tool_uses.len(), 1);
371 assert_eq!(back.tool_uses[0].name, "Read");
372 assert!(back.tool_uses[0].result.is_some());
373 }
374
375 #[test]
376 fn test_conversation_view_serde_roundtrip() {
377 let view = sample_view();
378 let json = serde_json::to_string(&view).unwrap();
379 let back: ConversationView = serde_json::from_str(&json).unwrap();
380 assert_eq!(back.id, "sess-1");
381 assert_eq!(back.turns.len(), 3);
382 }
383
384 #[test]
385 fn test_watcher_event_variants() {
386 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
387 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
388
389 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
390 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
391
392 let progress_event = WatcherEvent::Progress {
393 kind: "agent_progress".into(),
394 data: serde_json::json!({"status": "running"}),
395 };
396 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
397 }
398
399 #[test]
400 fn test_token_usage_default() {
401 let usage = TokenUsage::default();
402 assert!(usage.input_tokens.is_none());
403 assert!(usage.output_tokens.is_none());
404 }
405
406 #[test]
407 fn test_conversation_meta() {
408 let meta = ConversationMeta {
409 id: "sess-1".into(),
410 started_at: None,
411 last_activity: None,
412 message_count: 5,
413 file_path: Some("/tmp/test.jsonl".into()),
414 };
415 let json = serde_json::to_string(&meta).unwrap();
416 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
417 assert_eq!(back.message_count, 5);
418 }
419}