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(['/', '_', '.'], "-")
115}
116
117fn unsanitize_project_path(sanitized: &str) -> String {
118 sanitized.replace('-', "/")
119}
120
121mod dirs {
122 use super::*;
123
124 pub fn home_dir() -> Option<PathBuf> {
125 env::var_os("HOME")
126 .or_else(|| env::var_os("USERPROFILE"))
127 .map(PathBuf::from)
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use std::fs;
135 use tempfile::TempDir;
136
137 #[test]
138 fn test_path_resolution() {
139 let temp = TempDir::new().unwrap();
140 let resolver = PathResolver::new()
141 .with_home(temp.path())
142 .with_claude_dir(temp.path().join(".claude"));
143
144 let claude_dir = resolver.claude_dir().unwrap();
145 assert_eq!(claude_dir, temp.path().join(".claude"));
146
147 let projects_dir = resolver.projects_dir().unwrap();
148 assert_eq!(projects_dir, temp.path().join(".claude/projects"));
149
150 let history = resolver.history_file().unwrap();
151 assert_eq!(history, temp.path().join(".claude/history.jsonl"));
152 }
153
154 #[test]
155 fn test_project_path_sanitization() {
156 assert_eq!(
157 sanitize_project_path("/Users/alex/project"),
158 "-Users-alex-project"
159 );
160 assert_eq!(
161 unsanitize_project_path("-Users-alex-project"),
162 "/Users/alex/project"
163 );
164 }
165
166 #[test]
167 fn test_project_path_sanitization_with_dots() {
168 assert_eq!(
171 sanitize_project_path("/Users/alex/code/github.com/x/repo/.claude/worktrees/foo"),
172 "-Users-alex-code-github-com-x-repo--claude-worktrees-foo"
173 );
174 }
175
176 #[test]
177 fn test_conversation_file_path() {
178 let temp = TempDir::new().unwrap();
179 let resolver = PathResolver::new().with_claude_dir(temp.path());
180
181 let convo_file = resolver
182 .conversation_file("/Users/alex/project", "session-123")
183 .unwrap();
184
185 assert_eq!(
186 convo_file,
187 temp.path()
188 .join("projects/-Users-alex-project/session-123.jsonl")
189 );
190 }
191
192 #[test]
193 fn test_list_projects() {
194 let temp = TempDir::new().unwrap();
195 let projects_dir = temp.path().join("projects");
196 fs::create_dir_all(&projects_dir).unwrap();
197 fs::create_dir(projects_dir.join("-Users-alex-project1")).unwrap();
198 fs::create_dir(projects_dir.join("-Users-bob-project2")).unwrap();
199
200 let resolver = PathResolver::new().with_claude_dir(temp.path());
201 let projects = resolver.list_project_dirs().unwrap();
202
203 assert_eq!(projects.len(), 2);
204 assert!(projects.contains(&"/Users/alex/project1".to_string()));
205 assert!(projects.contains(&"/Users/bob/project2".to_string()));
206 }
207
208 #[test]
209 fn test_list_projects_empty() {
210 let temp = TempDir::new().unwrap();
211 let projects_dir = temp.path().join("projects");
212 fs::create_dir_all(&projects_dir).unwrap();
213
214 let resolver = PathResolver::new().with_claude_dir(temp.path());
215 let projects = resolver.list_project_dirs().unwrap();
216 assert!(projects.is_empty());
217 }
218
219 #[test]
220 fn test_list_projects_no_dir() {
221 let temp = TempDir::new().unwrap();
222 let resolver = PathResolver::new().with_claude_dir(temp.path());
224 let projects = resolver.list_project_dirs().unwrap();
225 assert!(projects.is_empty());
226 }
227
228 #[test]
229 fn test_list_conversations() {
230 let temp = TempDir::new().unwrap();
231 let project_dir = temp.path().join("projects/-test-project");
232 fs::create_dir_all(&project_dir).unwrap();
233 fs::write(project_dir.join("session-1.jsonl"), "{}").unwrap();
234 fs::write(project_dir.join("session-2.jsonl"), "{}").unwrap();
235 fs::write(project_dir.join("not-jsonl.txt"), "{}").unwrap();
236
237 let resolver = PathResolver::new().with_claude_dir(temp.path());
238 let sessions = resolver.list_conversations("/test/project").unwrap();
239 assert_eq!(sessions.len(), 2);
240 assert!(sessions.contains(&"session-1".to_string()));
241 assert!(sessions.contains(&"session-2".to_string()));
242 }
243
244 #[test]
245 fn test_list_conversations_empty_project() {
246 let temp = TempDir::new().unwrap();
247 let project_dir = temp.path().join("projects/-test-project");
248 fs::create_dir_all(&project_dir).unwrap();
249
250 let resolver = PathResolver::new().with_claude_dir(temp.path());
251 let sessions = resolver.list_conversations("/test/project").unwrap();
252 assert!(sessions.is_empty());
253 }
254
255 #[test]
256 fn test_list_conversations_no_project() {
257 let temp = TempDir::new().unwrap();
258 let resolver = PathResolver::new().with_claude_dir(temp.path());
259 let sessions = resolver.list_conversations("/nonexistent/project").unwrap();
260 assert!(sessions.is_empty());
261 }
262
263 #[test]
264 fn test_exists() {
265 let temp = TempDir::new().unwrap();
266 let resolver = PathResolver::new().with_claude_dir(temp.path());
267 assert!(resolver.exists());
268
269 let resolver2 = PathResolver::new().with_claude_dir("/nonexistent/dir");
270 assert!(!resolver2.exists());
271 }
272
273 #[test]
274 fn test_with_home() {
275 let resolver = PathResolver::new().with_home("/custom/home");
276 assert_eq!(
277 resolver.home_dir().unwrap().to_str().unwrap(),
278 "/custom/home"
279 );
280 }
281
282 #[test]
283 fn test_history_file() {
284 let temp = TempDir::new().unwrap();
285 let resolver = PathResolver::new().with_claude_dir(temp.path());
286 let hist = resolver.history_file().unwrap();
287 assert!(hist.ends_with("history.jsonl"));
288 }
289
290 #[test]
291 fn test_default_impl() {
292 let resolver = PathResolver::default();
293 let _ = resolver.claude_dir();
295 }
296}