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_key(|m| std::cmp::Reverse(m.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]
153 fn metadata_project_path_follows_on_disk_dir_not_jsonl_cwd() {
154 let temp = TempDir::new().unwrap();
155 let claude_dir = temp.path().join(".claude");
156 let on_disk = "/Users/alex/Devel/empathic/toolpath";
157 let dir = claude_dir.join("projects/-Users-alex-Devel-empathic-toolpath");
158 fs::create_dir_all(&dir).unwrap();
159 let foreign_cwd = "/Users/ben/empathic/oss/toolpath";
160 let entry = format!(
161 r#"{{"type":"user","uuid":"u1","timestamp":"2024-01-01T00:00:00Z","cwd":"{foreign_cwd}","message":{{"role":"user","content":"hi"}}}}"#,
162 );
163 fs::write(dir.join("imported-session.jsonl"), format!("{entry}\n")).unwrap();
164
165 let resolver = PathResolver::new().with_claude_dir(&claude_dir);
166 let io = ConvoIO::with_resolver(resolver);
167 let meta = io
168 .read_conversation_metadata(on_disk, "imported-session")
169 .unwrap();
170
171 assert_eq!(meta.project_path, on_disk);
172 assert_ne!(meta.project_path, foreign_cwd);
173 }
174
175 #[test]
176 fn test_list_conversations() {
177 let (_temp, io) = setup_io();
178 let sessions = io.list_conversations("/test/project").unwrap();
179 assert_eq!(sessions.len(), 1);
180 }
181
182 #[test]
183 fn test_list_conversation_metadata() {
184 let (_temp, io) = setup_io();
185 let meta = io.list_conversation_metadata("/test/project").unwrap();
186 assert_eq!(meta.len(), 1);
187 assert_eq!(meta[0].message_count, 2);
188 }
189
190 #[test]
191 fn test_list_projects() {
192 let (_temp, io) = setup_io();
193 let projects = io.list_projects().unwrap();
194 assert_eq!(projects.len(), 1);
195 }
196
197 #[test]
198 fn test_exists() {
199 let (_temp, io) = setup_io();
200 assert!(io.exists());
201 }
202
203 #[test]
204 fn test_claude_dir_path() {
205 let (_temp, io) = setup_io();
206 let path = io.claude_dir_path().unwrap();
207 assert!(path.exists());
208 }
209
210 #[test]
211 fn test_conversation_exists() {
212 let (_temp, io) = setup_io();
213 assert!(
214 io.conversation_exists("/test/project", "session-1")
215 .unwrap()
216 );
217 assert!(
218 !io.conversation_exists("/test/project", "nonexistent")
219 .unwrap()
220 );
221 }
222
223 #[test]
224 fn test_project_exists() {
225 let (_temp, io) = setup_io();
226 assert!(io.project_exists("/test/project"));
227 assert!(!io.project_exists("/nonexistent"));
228 }
229
230 #[test]
231 fn test_read_history_no_file() {
232 let (_temp, io) = setup_io();
233 let history = io.read_history().unwrap();
234 assert!(history.is_empty());
235 }
236
237 #[test]
238 fn test_resolver_accessor() {
239 let (_temp, io) = setup_io();
240 assert!(io.resolver().exists());
241 }
242}