1use crate::error::{ConvoError, Result};
2use std::env;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct PathResolver {
7 home_dir: Option<PathBuf>,
8 claude_dir: Option<PathBuf>,
9}
10
11impl Default for PathResolver {
12 fn default() -> Self {
13 Self::new()
14 }
15}
16
17impl PathResolver {
18 pub fn new() -> Self {
19 let home_dir = dirs::home_dir();
20 Self {
21 home_dir,
22 claude_dir: None,
23 }
24 }
25
26 pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
27 self.home_dir = Some(home.into());
28 self
29 }
30
31 pub fn with_claude_dir<P: Into<PathBuf>>(mut self, claude_dir: P) -> Self {
32 self.claude_dir = Some(claude_dir.into());
33 self
34 }
35
36 pub fn home_dir(&self) -> Result<&Path> {
37 self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
38 }
39
40 pub fn claude_dir(&self) -> Result<PathBuf> {
41 if let Some(ref claude_dir) = self.claude_dir {
42 return Ok(claude_dir.clone());
43 }
44
45 let home = self.home_dir()?;
46 Ok(home.join(".claude"))
47 }
48
49 pub fn projects_dir(&self) -> Result<PathBuf> {
50 Ok(self.claude_dir()?.join("projects"))
51 }
52
53 pub fn history_file(&self) -> Result<PathBuf> {
54 Ok(self.claude_dir()?.join("history.jsonl"))
55 }
56
57 pub fn project_dir(&self, project_path: &str) -> Result<PathBuf> {
58 let sanitized = sanitize_project_path(project_path);
59 Ok(self.projects_dir()?.join(sanitized))
60 }
61
62 pub fn conversation_file(&self, project_path: &str, session_id: &str) -> Result<PathBuf> {
63 Ok(self
64 .project_dir(project_path)?
65 .join(format!("{}.jsonl", session_id)))
66 }
67
68 pub fn list_project_dirs(&self) -> Result<Vec<String>> {
69 let projects_dir = self.projects_dir()?;
70 if !projects_dir.exists() {
71 return Ok(Vec::new());
72 }
73
74 let mut projects = Vec::new();
75 for entry in std::fs::read_dir(&projects_dir)? {
76 let entry = entry?;
77 if entry.file_type()?.is_dir()
78 && let Some(name) = entry.file_name().to_str()
79 {
80 projects.push(unsanitize_project_path(name));
81 }
82 }
83 Ok(projects)
84 }
85
86 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
87 let project_dir = self.project_dir(project_path)?;
88 if !project_dir.exists() {
89 return Ok(Vec::new());
90 }
91
92 let mut sessions = Vec::new();
93 for entry in std::fs::read_dir(&project_dir)? {
94 let entry = entry?;
95 let path = entry.path();
96 if path.extension().and_then(|s| s.to_str()) == Some("jsonl")
97 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
98 {
99 sessions.push(stem.to_string());
100 }
101 }
102 Ok(sessions)
103 }
104
105 pub fn exists(&self) -> bool {
106 self.claude_dir().map(|p| p.exists()).unwrap_or(false)
107 }
108}
109
110fn sanitize_project_path(path: &str) -> String {
111 path.replace(['/', '_'], "-")
113}
114
115fn unsanitize_project_path(sanitized: &str) -> String {
116 sanitized.replace('-', "/")
117}
118
119mod dirs {
120 use super::*;
121
122 pub fn home_dir() -> Option<PathBuf> {
123 env::var_os("HOME")
124 .or_else(|| env::var_os("USERPROFILE"))
125 .map(PathBuf::from)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use std::fs;
133 use tempfile::TempDir;
134
135 #[test]
136 fn test_path_resolution() {
137 let temp = TempDir::new().unwrap();
138 let resolver = PathResolver::new()
139 .with_home(temp.path())
140 .with_claude_dir(temp.path().join(".claude"));
141
142 let claude_dir = resolver.claude_dir().unwrap();
143 assert_eq!(claude_dir, temp.path().join(".claude"));
144
145 let projects_dir = resolver.projects_dir().unwrap();
146 assert_eq!(projects_dir, temp.path().join(".claude/projects"));
147
148 let history = resolver.history_file().unwrap();
149 assert_eq!(history, temp.path().join(".claude/history.jsonl"));
150 }
151
152 #[test]
153 fn test_project_path_sanitization() {
154 assert_eq!(
155 sanitize_project_path("/Users/alex/project"),
156 "-Users-alex-project"
157 );
158 assert_eq!(
159 unsanitize_project_path("-Users-alex-project"),
160 "/Users/alex/project"
161 );
162 }
163
164 #[test]
165 fn test_conversation_file_path() {
166 let temp = TempDir::new().unwrap();
167 let resolver = PathResolver::new().with_claude_dir(temp.path());
168
169 let convo_file = resolver
170 .conversation_file("/Users/alex/project", "session-123")
171 .unwrap();
172
173 assert_eq!(
174 convo_file,
175 temp.path()
176 .join("projects/-Users-alex-project/session-123.jsonl")
177 );
178 }
179
180 #[test]
181 fn test_list_projects() {
182 let temp = TempDir::new().unwrap();
183 let projects_dir = temp.path().join("projects");
184 fs::create_dir_all(&projects_dir).unwrap();
185 fs::create_dir(projects_dir.join("-Users-alex-project1")).unwrap();
186 fs::create_dir(projects_dir.join("-Users-bob-project2")).unwrap();
187
188 let resolver = PathResolver::new().with_claude_dir(temp.path());
189 let projects = resolver.list_project_dirs().unwrap();
190
191 assert_eq!(projects.len(), 2);
192 assert!(projects.contains(&"/Users/alex/project1".to_string()));
193 assert!(projects.contains(&"/Users/bob/project2".to_string()));
194 }
195
196 #[test]
197 fn test_list_projects_empty() {
198 let temp = TempDir::new().unwrap();
199 let projects_dir = temp.path().join("projects");
200 fs::create_dir_all(&projects_dir).unwrap();
201
202 let resolver = PathResolver::new().with_claude_dir(temp.path());
203 let projects = resolver.list_project_dirs().unwrap();
204 assert!(projects.is_empty());
205 }
206
207 #[test]
208 fn test_list_projects_no_dir() {
209 let temp = TempDir::new().unwrap();
210 let resolver = PathResolver::new().with_claude_dir(temp.path());
212 let projects = resolver.list_project_dirs().unwrap();
213 assert!(projects.is_empty());
214 }
215
216 #[test]
217 fn test_list_conversations() {
218 let temp = TempDir::new().unwrap();
219 let project_dir = temp.path().join("projects/-test-project");
220 fs::create_dir_all(&project_dir).unwrap();
221 fs::write(project_dir.join("session-1.jsonl"), "{}").unwrap();
222 fs::write(project_dir.join("session-2.jsonl"), "{}").unwrap();
223 fs::write(project_dir.join("not-jsonl.txt"), "{}").unwrap();
224
225 let resolver = PathResolver::new().with_claude_dir(temp.path());
226 let sessions = resolver.list_conversations("/test/project").unwrap();
227 assert_eq!(sessions.len(), 2);
228 assert!(sessions.contains(&"session-1".to_string()));
229 assert!(sessions.contains(&"session-2".to_string()));
230 }
231
232 #[test]
233 fn test_list_conversations_empty_project() {
234 let temp = TempDir::new().unwrap();
235 let project_dir = temp.path().join("projects/-test-project");
236 fs::create_dir_all(&project_dir).unwrap();
237
238 let resolver = PathResolver::new().with_claude_dir(temp.path());
239 let sessions = resolver.list_conversations("/test/project").unwrap();
240 assert!(sessions.is_empty());
241 }
242
243 #[test]
244 fn test_list_conversations_no_project() {
245 let temp = TempDir::new().unwrap();
246 let resolver = PathResolver::new().with_claude_dir(temp.path());
247 let sessions = resolver.list_conversations("/nonexistent/project").unwrap();
248 assert!(sessions.is_empty());
249 }
250
251 #[test]
252 fn test_exists() {
253 let temp = TempDir::new().unwrap();
254 let resolver = PathResolver::new().with_claude_dir(temp.path());
255 assert!(resolver.exists());
256
257 let resolver2 = PathResolver::new().with_claude_dir("/nonexistent/dir");
258 assert!(!resolver2.exists());
259 }
260
261 #[test]
262 fn test_with_home() {
263 let resolver = PathResolver::new().with_home("/custom/home");
264 assert_eq!(
265 resolver.home_dir().unwrap().to_str().unwrap(),
266 "/custom/home"
267 );
268 }
269
270 #[test]
271 fn test_history_file() {
272 let temp = TempDir::new().unwrap();
273 let resolver = PathResolver::new().with_claude_dir(temp.path());
274 let hist = resolver.history_file().unwrap();
275 assert!(hist.ends_with("history.jsonl"));
276 }
277
278 #[test]
279 fn test_default_impl() {
280 let resolver = PathResolver::default();
281 let _ = resolver.claude_dir();
283 }
284}