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 Progress {
184 kind: String,
185 data: serde_json::Value,
186 },
187}
188
189pub trait ConversationProvider {
196 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
198
199 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
201
202 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
204
205 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
207}
208
209pub trait ConversationWatcher {
211 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
213
214 fn seen_count(&self) -> usize;
216}
217
218#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn sample_view() -> ConversationView {
225 ConversationView {
226 id: "sess-1".into(),
227 started_at: None,
228 last_activity: None,
229 turns: vec![
230 Turn {
231 id: "t1".into(),
232 parent_id: None,
233 role: Role::User,
234 timestamp: "2026-01-01T00:00:00Z".into(),
235 text: "Fix the authentication bug in login.rs".into(),
236 thinking: None,
237 tool_uses: vec![],
238 model: None,
239 stop_reason: None,
240 token_usage: None,
241 extra: HashMap::new(),
242 },
243 Turn {
244 id: "t2".into(),
245 parent_id: Some("t1".into()),
246 role: Role::Assistant,
247 timestamp: "2026-01-01T00:00:01Z".into(),
248 text: "I'll fix that for you.".into(),
249 thinking: Some("The bug is in the token validation".into()),
250 tool_uses: vec![ToolInvocation {
251 id: "tool-1".into(),
252 name: "Read".into(),
253 input: serde_json::json!({"file": "src/login.rs"}),
254 result: Some(ToolResult {
255 content: "fn login() { ... }".into(),
256 is_error: false,
257 }),
258 }],
259 model: Some("claude-opus-4-6".into()),
260 stop_reason: Some("end_turn".into()),
261 token_usage: Some(TokenUsage {
262 input_tokens: Some(100),
263 output_tokens: Some(50),
264 }),
265 extra: HashMap::new(),
266 },
267 Turn {
268 id: "t3".into(),
269 parent_id: Some("t2".into()),
270 role: Role::User,
271 timestamp: "2026-01-01T00:00:02Z".into(),
272 text: "Thanks!".into(),
273 thinking: None,
274 tool_uses: vec![],
275 model: None,
276 stop_reason: None,
277 token_usage: None,
278 extra: HashMap::new(),
279 },
280 ],
281 }
282 }
283
284 #[test]
285 fn test_title_short() {
286 let view = sample_view();
287 let title = view.title(100).unwrap();
288 assert_eq!(title, "Fix the authentication bug in login.rs");
289 }
290
291 #[test]
292 fn test_title_truncated() {
293 let view = sample_view();
294 let title = view.title(10).unwrap();
295 assert_eq!(title, "Fix the au...");
296 }
297
298 #[test]
299 fn test_title_empty() {
300 let view = ConversationView {
301 id: "empty".into(),
302 started_at: None,
303 last_activity: None,
304 turns: vec![],
305 };
306 assert!(view.title(50).is_none());
307 }
308
309 #[test]
310 fn test_turns_by_role() {
311 let view = sample_view();
312 let users = view.turns_by_role(&Role::User);
313 assert_eq!(users.len(), 2);
314 let assistants = view.turns_by_role(&Role::Assistant);
315 assert_eq!(assistants.len(), 1);
316 }
317
318 #[test]
319 fn test_turns_since_middle() {
320 let view = sample_view();
321 let since = view.turns_since("t1");
322 assert_eq!(since.len(), 2);
323 assert_eq!(since[0].id, "t2");
324 }
325
326 #[test]
327 fn test_turns_since_last() {
328 let view = sample_view();
329 let since = view.turns_since("t3");
330 assert!(since.is_empty());
331 }
332
333 #[test]
334 fn test_turns_since_unknown() {
335 let view = sample_view();
336 let since = view.turns_since("nonexistent");
337 assert_eq!(since.len(), 3);
338 }
339
340 #[test]
341 fn test_role_display() {
342 assert_eq!(Role::User.to_string(), "user");
343 assert_eq!(Role::Assistant.to_string(), "assistant");
344 assert_eq!(Role::System.to_string(), "system");
345 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
346 }
347
348 #[test]
349 fn test_role_equality() {
350 assert_eq!(Role::User, Role::User);
351 assert_ne!(Role::User, Role::Assistant);
352 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
353 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
354 }
355
356 #[test]
357 fn test_turn_serde_roundtrip() {
358 let turn = &sample_view().turns[1];
359 let json = serde_json::to_string(turn).unwrap();
360 let back: Turn = serde_json::from_str(&json).unwrap();
361 assert_eq!(back.id, "t2");
362 assert_eq!(back.model, Some("claude-opus-4-6".into()));
363 assert_eq!(back.tool_uses.len(), 1);
364 assert_eq!(back.tool_uses[0].name, "Read");
365 assert!(back.tool_uses[0].result.is_some());
366 }
367
368 #[test]
369 fn test_conversation_view_serde_roundtrip() {
370 let view = sample_view();
371 let json = serde_json::to_string(&view).unwrap();
372 let back: ConversationView = serde_json::from_str(&json).unwrap();
373 assert_eq!(back.id, "sess-1");
374 assert_eq!(back.turns.len(), 3);
375 }
376
377 #[test]
378 fn test_watcher_event_variants() {
379 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
380 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
381
382 let progress_event = WatcherEvent::Progress {
383 kind: "agent_progress".into(),
384 data: serde_json::json!({"status": "running"}),
385 };
386 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
387 }
388
389 #[test]
390 fn test_token_usage_default() {
391 let usage = TokenUsage::default();
392 assert!(usage.input_tokens.is_none());
393 assert!(usage.output_tokens.is_none());
394 }
395
396 #[test]
397 fn test_conversation_meta() {
398 let meta = ConversationMeta {
399 id: "sess-1".into(),
400 started_at: None,
401 last_activity: None,
402 message_count: 5,
403 file_path: Some("/tmp/test.jsonl".into()),
404 };
405 let json = serde_json::to_string(&meta).unwrap();
406 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
407 assert_eq!(back.message_count, 5);
408 }
409}