1#![doc = include_str!("../README.md")]
2
3pub mod derive;
4pub mod extract;
5pub mod project;
6
7pub use derive::{DeriveConfig, derive_path, file_write_diff, unified_diff};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14#[derive(Debug, thiserror::Error)]
18pub enum ConvoError {
19 #[error("I/O error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("JSON error: {0}")]
23 Json(#[from] serde_json::Error),
24
25 #[error("provider error: {0}")]
26 Provider(String),
27
28 #[error("{0}")]
29 Other(#[from] Box<dyn std::error::Error + Send + Sync>),
30}
31
32pub type Result<T> = std::result::Result<T, ConvoError>;
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub enum Role {
39 User,
40 Assistant,
41 System,
42 Other(String),
44}
45
46impl std::fmt::Display for Role {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Role::User => write!(f, "user"),
50 Role::Assistant => write!(f, "assistant"),
51 Role::System => write!(f, "system"),
52 Role::Other(s) => write!(f, "{}", s),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct TokenUsage {
60 pub input_tokens: Option<u32>,
62 pub output_tokens: Option<u32>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub cache_read_tokens: Option<u32>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub cache_write_tokens: Option<u32>,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct EnvironmentSnapshot {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub working_dir: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub vcs_branch: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub vcs_revision: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DelegatedWork {
91 pub agent_id: String,
93 pub prompt: String,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub turns: Vec<Turn>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub result: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ConversationEvent {
110 pub id: String,
112 pub timestamp: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub parent_id: Option<String>,
117 pub event_type: String,
119 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
121 pub data: HashMap<String, serde_json::Value>,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum ToolCategory {
133 FileRead,
135 FileWrite,
137 FileSearch,
139 Shell,
141 Network,
143 Delegation,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ToolInvocation {
150 pub id: String,
152 pub name: String,
154 pub input: serde_json::Value,
156 pub result: Option<ToolResult>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub category: Option<ToolCategory>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ToolResult {
167 pub content: String,
169 pub is_error: bool,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct Turn {
176 pub id: String,
178
179 pub parent_id: Option<String>,
181
182 pub role: Role,
184
185 pub timestamp: String,
187
188 pub text: String,
190
191 pub thinking: Option<String>,
193
194 pub tool_uses: Vec<ToolInvocation>,
196
197 pub model: Option<String>,
199
200 pub stop_reason: Option<String>,
202
203 pub token_usage: Option<TokenUsage>,
205
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub environment: Option<EnvironmentSnapshot>,
209
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub delegations: Vec<DelegatedWork>,
213
214 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
220 pub extra: HashMap<String, serde_json::Value>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ConversationView {
226 pub id: String,
228
229 pub started_at: Option<DateTime<Utc>>,
231
232 pub last_activity: Option<DateTime<Utc>>,
234
235 pub turns: Vec<Turn>,
237
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub total_usage: Option<TokenUsage>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub provider_id: Option<String>,
245
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub files_changed: Vec<String>,
250
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub session_ids: Vec<String>,
256
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
261 pub events: Vec<ConversationEvent>,
262}
263
264impl ConversationView {
265 pub fn title(&self, max_len: usize) -> Option<String> {
267 let text = self
268 .turns
269 .iter()
270 .find(|t| t.role == Role::User && !t.text.is_empty())
271 .map(|t| &t.text)?;
272
273 if text.chars().count() > max_len {
274 let truncated: String = text.chars().take(max_len).collect();
275 Some(format!("{}...", truncated))
276 } else {
277 Some(text.clone())
278 }
279 }
280
281 pub fn turns_by_role(&self, role: &Role) -> Vec<&Turn> {
283 self.turns.iter().filter(|t| &t.role == role).collect()
284 }
285
286 pub fn turns_since(&self, turn_id: &str) -> &[Turn] {
291 match self.turns.iter().position(|t| t.id == turn_id) {
292 Some(idx) if idx + 1 < self.turns.len() => &self.turns[idx + 1..],
293 Some(_) => &[],
294 None => &self.turns,
295 }
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ConversationMeta {
306 pub id: String,
308 pub started_at: Option<DateTime<Utc>>,
310 pub last_activity: Option<DateTime<Utc>>,
312 pub message_count: usize,
314 pub file_path: Option<PathBuf>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub predecessor: Option<SessionLink>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub successor: Option<SessionLink>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub enum SessionLinkKind {
329 Rotation,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct SessionLink {
336 pub session_id: String,
338 pub kind: SessionLinkKind,
340}
341
342#[derive(Debug, Clone)]
375pub enum WatcherEvent {
376 Turn(Box<Turn>),
378
379 TurnUpdated(Box<Turn>),
385
386 Progress {
388 kind: String,
389 data: serde_json::Value,
390 },
391}
392
393impl WatcherEvent {
394 pub fn as_turn(&self) -> Option<&Turn> {
397 match self {
398 WatcherEvent::Turn(t) | WatcherEvent::TurnUpdated(t) => Some(t),
399 WatcherEvent::Progress { .. } => None,
400 }
401 }
402
403 pub fn as_progress(&self) -> Option<(&str, &serde_json::Value)> {
405 match self {
406 WatcherEvent::Progress { kind, data } => Some((kind, data)),
407 _ => None,
408 }
409 }
410
411 pub fn is_update(&self) -> bool {
413 matches!(self, WatcherEvent::TurnUpdated(_))
414 }
415
416 pub fn turn_id(&self) -> Option<&str> {
418 self.as_turn().map(|t| t.id.as_str())
419 }
420}
421
422pub trait ConversationProvider {
429 fn list_conversations(&self, project: &str) -> Result<Vec<String>>;
431
432 fn load_conversation(&self, project: &str, conversation_id: &str) -> Result<ConversationView>;
434
435 fn load_metadata(&self, project: &str, conversation_id: &str) -> Result<ConversationMeta>;
437
438 fn list_metadata(&self, project: &str) -> Result<Vec<ConversationMeta>>;
440}
441
442pub trait ConversationWatcher {
444 fn poll(&mut self) -> Result<Vec<WatcherEvent>>;
446
447 fn seen_count(&self) -> usize;
449}
450
451pub use extract::extract_conversation;
452pub use project::{AnyProjector, ConversationProjector};
453
454#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn sample_view() -> ConversationView {
461 ConversationView {
462 id: "sess-1".into(),
463 started_at: None,
464 last_activity: None,
465 turns: vec![
466 Turn {
467 id: "t1".into(),
468 parent_id: None,
469 role: Role::User,
470 timestamp: "2026-01-01T00:00:00Z".into(),
471 text: "Fix the authentication bug in login.rs".into(),
472 thinking: None,
473 tool_uses: vec![],
474 model: None,
475 stop_reason: None,
476 token_usage: None,
477 environment: None,
478 delegations: vec![],
479 extra: HashMap::new(),
480 },
481 Turn {
482 id: "t2".into(),
483 parent_id: Some("t1".into()),
484 role: Role::Assistant,
485 timestamp: "2026-01-01T00:00:01Z".into(),
486 text: "I'll fix that for you.".into(),
487 thinking: Some("The bug is in the token validation".into()),
488 tool_uses: vec![ToolInvocation {
489 id: "tool-1".into(),
490 name: "Read".into(),
491 input: serde_json::json!({"file": "src/login.rs"}),
492 result: Some(ToolResult {
493 content: "fn login() { ... }".into(),
494 is_error: false,
495 }),
496 category: Some(ToolCategory::FileRead),
497 }],
498 model: Some("claude-opus-4-6".into()),
499 stop_reason: Some("end_turn".into()),
500 token_usage: Some(TokenUsage {
501 input_tokens: Some(100),
502 output_tokens: Some(50),
503 cache_read_tokens: None,
504 cache_write_tokens: None,
505 }),
506 environment: None,
507 delegations: vec![],
508 extra: HashMap::new(),
509 },
510 Turn {
511 id: "t3".into(),
512 parent_id: Some("t2".into()),
513 role: Role::User,
514 timestamp: "2026-01-01T00:00:02Z".into(),
515 text: "Thanks!".into(),
516 thinking: None,
517 tool_uses: vec![],
518 model: None,
519 stop_reason: None,
520 token_usage: None,
521 environment: None,
522 delegations: vec![],
523 extra: HashMap::new(),
524 },
525 ],
526 total_usage: None,
527 provider_id: None,
528 files_changed: vec![],
529 session_ids: vec![],
530 events: vec![],
531 }
532 }
533
534 #[test]
535 fn test_title_short() {
536 let view = sample_view();
537 let title = view.title(100).unwrap();
538 assert_eq!(title, "Fix the authentication bug in login.rs");
539 }
540
541 #[test]
542 fn test_title_truncated() {
543 let view = sample_view();
544 let title = view.title(10).unwrap();
545 assert_eq!(title, "Fix the au...");
546 }
547
548 #[test]
549 fn test_title_empty() {
550 let view = ConversationView {
551 id: "empty".into(),
552 started_at: None,
553 last_activity: None,
554 turns: vec![],
555 total_usage: None,
556 provider_id: None,
557 files_changed: vec![],
558 session_ids: vec![],
559 events: vec![],
560 };
561 assert!(view.title(50).is_none());
562 }
563
564 #[test]
565 fn test_turns_by_role() {
566 let view = sample_view();
567 let users = view.turns_by_role(&Role::User);
568 assert_eq!(users.len(), 2);
569 let assistants = view.turns_by_role(&Role::Assistant);
570 assert_eq!(assistants.len(), 1);
571 }
572
573 #[test]
574 fn test_turns_since_middle() {
575 let view = sample_view();
576 let since = view.turns_since("t1");
577 assert_eq!(since.len(), 2);
578 assert_eq!(since[0].id, "t2");
579 }
580
581 #[test]
582 fn test_turns_since_last() {
583 let view = sample_view();
584 let since = view.turns_since("t3");
585 assert!(since.is_empty());
586 }
587
588 #[test]
589 fn test_turns_since_unknown() {
590 let view = sample_view();
591 let since = view.turns_since("nonexistent");
592 assert_eq!(since.len(), 3);
593 }
594
595 #[test]
596 fn test_role_display() {
597 assert_eq!(Role::User.to_string(), "user");
598 assert_eq!(Role::Assistant.to_string(), "assistant");
599 assert_eq!(Role::System.to_string(), "system");
600 assert_eq!(Role::Other("tool".into()).to_string(), "tool");
601 }
602
603 #[test]
604 fn test_role_equality() {
605 assert_eq!(Role::User, Role::User);
606 assert_ne!(Role::User, Role::Assistant);
607 assert_eq!(Role::Other("x".into()), Role::Other("x".into()));
608 assert_ne!(Role::Other("x".into()), Role::Other("y".into()));
609 }
610
611 #[test]
612 fn test_turn_serde_roundtrip() {
613 let turn = &sample_view().turns[1];
614 let json = serde_json::to_string(turn).unwrap();
615 let back: Turn = serde_json::from_str(&json).unwrap();
616 assert_eq!(back.id, "t2");
617 assert_eq!(back.model, Some("claude-opus-4-6".into()));
618 assert_eq!(back.tool_uses.len(), 1);
619 assert_eq!(back.tool_uses[0].name, "Read");
620 assert!(back.tool_uses[0].result.is_some());
621 }
622
623 #[test]
624 fn test_conversation_view_serde_roundtrip() {
625 let view = sample_view();
626 let json = serde_json::to_string(&view).unwrap();
627 let back: ConversationView = serde_json::from_str(&json).unwrap();
628 assert_eq!(back.id, "sess-1");
629 assert_eq!(back.turns.len(), 3);
630 }
631
632 #[test]
633 fn test_watcher_event_variants() {
634 let turn_event = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
635 assert!(matches!(turn_event, WatcherEvent::Turn(_)));
636
637 let updated_event = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[1].clone()));
638 assert!(matches!(updated_event, WatcherEvent::TurnUpdated(_)));
639
640 let progress_event = WatcherEvent::Progress {
641 kind: "agent_progress".into(),
642 data: serde_json::json!({"status": "running"}),
643 };
644 assert!(matches!(progress_event, WatcherEvent::Progress { .. }));
645 }
646
647 #[test]
648 fn test_watcher_event_as_turn() {
649 let turn = sample_view().turns[0].clone();
650 let event = WatcherEvent::Turn(Box::new(turn.clone()));
651 assert_eq!(event.as_turn().unwrap().id, "t1");
652
653 let updated = WatcherEvent::TurnUpdated(Box::new(turn));
654 assert_eq!(updated.as_turn().unwrap().id, "t1");
655
656 let progress = WatcherEvent::Progress {
657 kind: "test".into(),
658 data: serde_json::Value::Null,
659 };
660 assert!(progress.as_turn().is_none());
661 }
662
663 #[test]
664 fn test_watcher_event_as_progress() {
665 let progress = WatcherEvent::Progress {
666 kind: "hook_progress".into(),
667 data: serde_json::json!({"hookName": "pre-commit"}),
668 };
669 let (kind, data) = progress.as_progress().unwrap();
670 assert_eq!(kind, "hook_progress");
671 assert_eq!(data["hookName"], "pre-commit");
672
673 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
674 assert!(turn.as_progress().is_none());
675 }
676
677 #[test]
678 fn test_watcher_event_is_update() {
679 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[0].clone()));
680 assert!(!turn.is_update());
681
682 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
683 assert!(updated.is_update());
684
685 let progress = WatcherEvent::Progress {
686 kind: "test".into(),
687 data: serde_json::Value::Null,
688 };
689 assert!(!progress.is_update());
690 }
691
692 #[test]
693 fn test_watcher_event_turn_id() {
694 let turn = WatcherEvent::Turn(Box::new(sample_view().turns[1].clone()));
695 assert_eq!(turn.turn_id(), Some("t2"));
696
697 let updated = WatcherEvent::TurnUpdated(Box::new(sample_view().turns[0].clone()));
698 assert_eq!(updated.turn_id(), Some("t1"));
699
700 let progress = WatcherEvent::Progress {
701 kind: "test".into(),
702 data: serde_json::Value::Null,
703 };
704 assert!(progress.turn_id().is_none());
705 }
706
707 #[test]
708 fn test_token_usage_default() {
709 let usage = TokenUsage::default();
710 assert!(usage.input_tokens.is_none());
711 assert!(usage.output_tokens.is_none());
712 assert!(usage.cache_read_tokens.is_none());
713 assert!(usage.cache_write_tokens.is_none());
714 }
715
716 #[test]
717 fn test_token_usage_cache_fields_serde() {
718 let usage = TokenUsage {
719 input_tokens: Some(100),
720 output_tokens: Some(50),
721 cache_read_tokens: Some(500),
722 cache_write_tokens: Some(200),
723 };
724 let json = serde_json::to_string(&usage).unwrap();
725 let back: TokenUsage = serde_json::from_str(&json).unwrap();
726 assert_eq!(back.cache_read_tokens, Some(500));
727 assert_eq!(back.cache_write_tokens, Some(200));
728 }
729
730 #[test]
731 fn test_token_usage_cache_fields_omitted() {
732 let json = r#"{"input_tokens":100,"output_tokens":50}"#;
734 let usage: TokenUsage = serde_json::from_str(json).unwrap();
735 assert_eq!(usage.input_tokens, Some(100));
736 assert!(usage.cache_read_tokens.is_none());
737 assert!(usage.cache_write_tokens.is_none());
738 }
739
740 #[test]
741 fn test_environment_snapshot_serde() {
742 let env = EnvironmentSnapshot {
743 working_dir: Some("/home/user/project".into()),
744 vcs_branch: Some("main".into()),
745 vcs_revision: Some("abc123".into()),
746 };
747 let json = serde_json::to_string(&env).unwrap();
748 let back: EnvironmentSnapshot = serde_json::from_str(&json).unwrap();
749 assert_eq!(back.working_dir.as_deref(), Some("/home/user/project"));
750 assert_eq!(back.vcs_branch.as_deref(), Some("main"));
751 assert_eq!(back.vcs_revision.as_deref(), Some("abc123"));
752 }
753
754 #[test]
755 fn test_environment_snapshot_default() {
756 let env = EnvironmentSnapshot::default();
757 assert!(env.working_dir.is_none());
758 assert!(env.vcs_branch.is_none());
759 assert!(env.vcs_revision.is_none());
760 }
761
762 #[test]
763 fn test_environment_snapshot_skip_none_fields() {
764 let env = EnvironmentSnapshot {
765 working_dir: Some("/tmp".into()),
766 vcs_branch: None,
767 vcs_revision: None,
768 };
769 let json = serde_json::to_string(&env).unwrap();
770 assert!(!json.contains("vcs_branch"));
771 assert!(!json.contains("vcs_revision"));
772 }
773
774 #[test]
775 fn test_delegated_work_serde() {
776 let dw = DelegatedWork {
777 agent_id: "agent-123".into(),
778 prompt: "Search for the bug".into(),
779 turns: vec![],
780 result: Some("Found the bug in auth.rs".into()),
781 };
782 let json = serde_json::to_string(&dw).unwrap();
783 assert!(!json.contains("turns")); let back: DelegatedWork = serde_json::from_str(&json).unwrap();
785 assert_eq!(back.agent_id, "agent-123");
786 assert_eq!(back.result.as_deref(), Some("Found the bug in auth.rs"));
787 assert!(back.turns.is_empty());
788 }
789
790 #[test]
791 fn test_tool_category_serde() {
792 let ti = ToolInvocation {
793 id: "t1".into(),
794 name: "Bash".into(),
795 input: serde_json::json!({"command": "ls"}),
796 result: None,
797 category: Some(ToolCategory::Shell),
798 };
799 let json = serde_json::to_string(&ti).unwrap();
800 assert!(json.contains("\"shell\""));
801 let back: ToolInvocation = serde_json::from_str(&json).unwrap();
802 assert_eq!(back.category, Some(ToolCategory::Shell));
803 }
804
805 #[test]
806 fn test_tool_category_none_skipped() {
807 let ti = ToolInvocation {
808 id: "t1".into(),
809 name: "CustomTool".into(),
810 input: serde_json::json!({}),
811 result: None,
812 category: None,
813 };
814 let json = serde_json::to_string(&ti).unwrap();
815 assert!(!json.contains("category"));
816 }
817
818 #[test]
819 fn test_tool_category_missing_defaults_none() {
820 let json = r#"{"id":"t1","name":"Read","input":{},"result":null}"#;
822 let ti: ToolInvocation = serde_json::from_str(json).unwrap();
823 assert!(ti.category.is_none());
824 }
825
826 #[test]
827 fn test_tool_category_all_variants_roundtrip() {
828 let variants = vec![
829 ToolCategory::FileRead,
830 ToolCategory::FileWrite,
831 ToolCategory::FileSearch,
832 ToolCategory::Shell,
833 ToolCategory::Network,
834 ToolCategory::Delegation,
835 ];
836 for cat in variants {
837 let json = serde_json::to_value(cat).unwrap();
838 let back: ToolCategory = serde_json::from_value(json).unwrap();
839 assert_eq!(back, cat);
840 }
841 }
842
843 #[test]
844 fn test_turn_with_environment_and_delegations() {
845 let turn = Turn {
846 id: "t1".into(),
847 parent_id: None,
848 role: Role::Assistant,
849 timestamp: "2026-01-01T00:00:00Z".into(),
850 text: "Delegating...".into(),
851 thinking: None,
852 tool_uses: vec![],
853 model: None,
854 stop_reason: None,
855 token_usage: None,
856 environment: Some(EnvironmentSnapshot {
857 working_dir: Some("/project".into()),
858 vcs_branch: Some("feat/auth".into()),
859 vcs_revision: None,
860 }),
861 delegations: vec![DelegatedWork {
862 agent_id: "sub-1".into(),
863 prompt: "Find the bug".into(),
864 turns: vec![],
865 result: None,
866 }],
867 extra: HashMap::new(),
868 };
869 let json = serde_json::to_string(&turn).unwrap();
870 let back: Turn = serde_json::from_str(&json).unwrap();
871 assert_eq!(
872 back.environment.as_ref().unwrap().vcs_branch.as_deref(),
873 Some("feat/auth")
874 );
875 assert_eq!(back.delegations.len(), 1);
876 assert_eq!(back.delegations[0].agent_id, "sub-1");
877 }
878
879 #[test]
880 fn test_turn_without_new_fields_deserializes() {
881 let json = r#"{"id":"t1","parent_id":null,"role":"User","timestamp":"2026-01-01T00:00:00Z","text":"hi","thinking":null,"tool_uses":[],"model":null,"stop_reason":null,"token_usage":null}"#;
883 let turn: Turn = serde_json::from_str(json).unwrap();
884 assert!(turn.environment.is_none());
885 assert!(turn.delegations.is_empty());
886 }
887
888 #[test]
889 fn test_conversation_view_new_fields_serde() {
890 let view = ConversationView {
891 id: "s1".into(),
892 started_at: None,
893 last_activity: None,
894 turns: vec![],
895 total_usage: Some(TokenUsage {
896 input_tokens: Some(1000),
897 output_tokens: Some(500),
898 cache_read_tokens: Some(800),
899 cache_write_tokens: None,
900 }),
901 provider_id: Some("claude-code".into()),
902 files_changed: vec!["src/main.rs".into(), "src/lib.rs".into()],
903 session_ids: vec![],
904 events: vec![],
905 };
906 let json = serde_json::to_string(&view).unwrap();
907 let back: ConversationView = serde_json::from_str(&json).unwrap();
908 assert_eq!(back.provider_id.as_deref(), Some("claude-code"));
909 assert_eq!(back.files_changed, vec!["src/main.rs", "src/lib.rs"]);
910 assert_eq!(back.total_usage.as_ref().unwrap().input_tokens, Some(1000));
911 assert_eq!(
912 back.total_usage.as_ref().unwrap().cache_read_tokens,
913 Some(800)
914 );
915 }
916
917 #[test]
918 fn test_conversation_view_old_format_deserializes() {
919 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
921 let view: ConversationView = serde_json::from_str(json).unwrap();
922 assert!(view.total_usage.is_none());
923 assert!(view.provider_id.is_none());
924 assert!(view.files_changed.is_empty());
925 }
926
927 #[test]
928 fn test_conversation_meta() {
929 let meta = ConversationMeta {
930 id: "sess-1".into(),
931 started_at: None,
932 last_activity: None,
933 message_count: 5,
934 file_path: Some("/tmp/test.jsonl".into()),
935 predecessor: None,
936 successor: None,
937 };
938 let json = serde_json::to_string(&meta).unwrap();
939 let back: ConversationMeta = serde_json::from_str(&json).unwrap();
940 assert_eq!(back.message_count, 5);
941 }
942
943 #[test]
944 fn test_conversation_event_serde_roundtrip() {
945 let event = ConversationEvent {
946 id: "evt-1".into(),
947 timestamp: "2026-01-01T00:00:00Z".into(),
948 parent_id: Some("t1".into()),
949 event_type: "attachment".into(),
950 data: {
951 let mut m = HashMap::new();
952 m.insert("cwd".into(), serde_json::json!("/project"));
953 m.insert("version".into(), serde_json::json!("1.0"));
954 m
955 },
956 };
957 let json = serde_json::to_string(&event).unwrap();
958 let back: ConversationEvent = serde_json::from_str(&json).unwrap();
959 assert_eq!(back.id, "evt-1");
960 assert_eq!(back.event_type, "attachment");
961 assert_eq!(back.parent_id.as_deref(), Some("t1"));
962 assert_eq!(back.data["cwd"], serde_json::json!("/project"));
963 }
964
965 #[test]
966 fn test_conversation_event_empty_data_omitted() {
967 let event = ConversationEvent {
968 id: "evt-2".into(),
969 timestamp: "2026-01-01T00:00:00Z".into(),
970 parent_id: None,
971 event_type: "system".into(),
972 data: HashMap::new(),
973 };
974 let json = serde_json::to_string(&event).unwrap();
975 assert!(!json.contains("data"));
976 assert!(!json.contains("parent_id"));
977 }
978
979 #[test]
980 fn test_conversation_view_with_events_serde() {
981 let view = ConversationView {
982 id: "s1".into(),
983 started_at: None,
984 last_activity: None,
985 turns: vec![],
986 total_usage: None,
987 provider_id: None,
988 files_changed: vec![],
989 session_ids: vec![],
990 events: vec![ConversationEvent {
991 id: "evt-1".into(),
992 timestamp: "2026-01-01T00:00:00Z".into(),
993 parent_id: None,
994 event_type: "attachment".into(),
995 data: HashMap::new(),
996 }],
997 };
998 let json = serde_json::to_string(&view).unwrap();
999 assert!(json.contains("events"));
1000 let back: ConversationView = serde_json::from_str(&json).unwrap();
1001 assert_eq!(back.events.len(), 1);
1002 assert_eq!(back.events[0].event_type, "attachment");
1003 }
1004
1005 #[test]
1006 fn test_conversation_view_empty_events_omitted() {
1007 let view = ConversationView {
1008 id: "s1".into(),
1009 started_at: None,
1010 last_activity: None,
1011 turns: vec![],
1012 total_usage: None,
1013 provider_id: None,
1014 files_changed: vec![],
1015 session_ids: vec![],
1016 events: vec![],
1017 };
1018 let json = serde_json::to_string(&view).unwrap();
1019 assert!(!json.contains("events"));
1020 }
1021
1022 #[test]
1023 fn test_conversation_view_old_format_no_events() {
1024 let json = r#"{"id":"s1","started_at":null,"last_activity":null,"turns":[]}"#;
1026 let view: ConversationView = serde_json::from_str(json).unwrap();
1027 assert!(view.events.is_empty());
1028 }
1029}