1use crate::error::Result;
2use crate::paths::PathResolver;
3use crate::reader::ConversationReader;
4use crate::types::{Conversation, ConversationMetadata, HistoryEntry};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone)]
8pub struct ConvoIO {
9 resolver: PathResolver,
10}
11
12impl ConvoIO {
13 pub fn new() -> Self {
14 Self {
15 resolver: PathResolver::new(),
16 }
17 }
18
19 pub fn with_resolver(resolver: PathResolver) -> Self {
20 Self { resolver }
21 }
22
23 pub fn resolver(&self) -> &PathResolver {
24 &self.resolver
25 }
26
27 pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
28 let path = self.resolver.conversation_file(project_path, session_id)?;
29 ConversationReader::read_conversation(&path)
30 }
31
32 pub fn read_conversation_metadata(
33 &self,
34 project_path: &str,
35 session_id: &str,
36 ) -> Result<ConversationMetadata> {
37 let path = self.resolver.conversation_file(project_path, session_id)?;
38 ConversationReader::read_conversation_metadata(&path)
39 }
40
41 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
42 self.resolver.list_conversations(project_path)
43 }
44
45 pub fn list_conversation_metadata(
46 &self,
47 project_path: &str,
48 ) -> Result<Vec<ConversationMetadata>> {
49 let sessions = self.list_conversations(project_path)?;
50 let mut metadata = Vec::new();
51
52 for session_id in sessions {
53 match self.read_conversation_metadata(project_path, &session_id) {
54 Ok(meta) => metadata.push(meta),
55 Err(e) => {
56 eprintln!("Warning: Failed to read metadata for {}: {}", session_id, e);
57 }
58 }
59 }
60
61 metadata.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
62 Ok(metadata)
63 }
64
65 pub fn list_projects(&self) -> Result<Vec<String>> {
66 self.resolver.list_project_dirs()
67 }
68
69 pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
70 let path = self.resolver.history_file()?;
71 ConversationReader::read_history(&path)
72 }
73
74 pub fn exists(&self) -> bool {
75 self.resolver.exists()
76 }
77
78 pub fn claude_dir_path(&self) -> Result<PathBuf> {
79 self.resolver.claude_dir()
80 }
81
82 pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
83 let path = self.resolver.conversation_file(project_path, session_id)?;
84 Ok(path.exists())
85 }
86
87 pub fn project_exists(&self, project_path: &str) -> bool {
88 self.resolver
89 .project_dir(project_path)
90 .map(|p| p.exists())
91 .unwrap_or(false)
92 }
93}
94
95impl Default for ConvoIO {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use std::fs;
105 use tempfile::TempDir;
106
107 fn setup_io() -> (TempDir, ConvoIO) {
108 let temp = TempDir::new().unwrap();
109 let claude_dir = temp.path().join(".claude");
110 let project_dir = claude_dir.join("projects/-test-project");
111 fs::create_dir_all(&project_dir).unwrap();
112
113 let entry1 = r#"{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
114 let entry2 = r#"{"type":"assistant","uuid":"u2","timestamp":"2024-01-01T00:01:00Z","message":{"role":"assistant","content":"Hi"}}"#;
115 fs::write(
116 project_dir.join("session-1.jsonl"),
117 format!("{}\n{}\n", entry1, entry2),
118 )
119 .unwrap();
120
121 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
122 let io = ConvoIO::with_resolver(resolver);
123 (temp, io)
124 }
125
126 #[test]
127 fn test_default() {
128 let _io = ConvoIO::default();
129 }
130
131 #[test]
132 fn test_read_conversation() {
133 let (_temp, io) = setup_io();
134 let convo = io.read_conversation("/test/project", "session-1").unwrap();
135 assert_eq!(convo.entries.len(), 2);
136 }
137
138 #[test]
139 fn test_read_conversation_metadata() {
140 let (_temp, io) = setup_io();
141 let meta = io
142 .read_conversation_metadata("/test/project", "session-1")
143 .unwrap();
144 assert_eq!(meta.message_count, 2);
145 }
146
147 #[test]
148 fn test_list_conversations() {
149 let (_temp, io) = setup_io();
150 let sessions = io.list_conversations("/test/project").unwrap();
151 assert_eq!(sessions.len(), 1);
152 }
153
154 #[test]
155 fn test_list_conversation_metadata() {
156 let (_temp, io) = setup_io();
157 let meta = io.list_conversation_metadata("/test/project").unwrap();
158 assert_eq!(meta.len(), 1);
159 assert_eq!(meta[0].message_count, 2);
160 }
161
162 #[test]
163 fn test_list_projects() {
164 let (_temp, io) = setup_io();
165 let projects = io.list_projects().unwrap();
166 assert_eq!(projects.len(), 1);
167 }
168
169 #[test]
170 fn test_exists() {
171 let (_temp, io) = setup_io();
172 assert!(io.exists());
173 }
174
175 #[test]
176 fn test_claude_dir_path() {
177 let (_temp, io) = setup_io();
178 let path = io.claude_dir_path().unwrap();
179 assert!(path.exists());
180 }
181
182 #[test]
183 fn test_conversation_exists() {
184 let (_temp, io) = setup_io();
185 assert!(
186 io.conversation_exists("/test/project", "session-1")
187 .unwrap()
188 );
189 assert!(
190 !io.conversation_exists("/test/project", "nonexistent")
191 .unwrap()
192 );
193 }
194
195 #[test]
196 fn test_project_exists() {
197 let (_temp, io) = setup_io();
198 assert!(io.project_exists("/test/project"));
199 assert!(!io.project_exists("/nonexistent"));
200 }
201
202 #[test]
203 fn test_read_history_no_file() {
204 let (_temp, io) = setup_io();
205 let history = io.read_history().unwrap();
206 assert!(history.is_empty());
207 }
208
209 #[test]
210 fn test_resolver_accessor() {
211 let (_temp, io) = setup_io();
212 assert!(io.resolver().exists());
213 }
214}