1use std::fs;
2use std::path::Path;
3
4pub fn encode_path_for_claude(path: &str) -> String {
7 path.chars()
8 .map(|c| {
9 if c.is_ascii_alphanumeric() || c == '-' {
10 c
11 } else {
12 '-'
13 }
14 })
15 .collect()
16}
17
18pub fn walk_fs_for_path(current_dir: &str, remaining_encoded: &str) -> Option<String> {
22 walk_fs_recursive(current_dir, remaining_encoded, remaining_encoded)
23}
24
25fn walk_fs_recursive(
26 current_dir: &str,
27 remaining_encoded: &str,
28 full_target: &str,
29) -> Option<String> {
30 if remaining_encoded.is_empty() {
31 let encoded = encode_path_for_claude(current_dir);
33 if encoded.strip_prefix('-') == Some(full_target) {
34 return Some(current_dir.to_string());
35 }
36 return None;
37 }
38
39 let entries = fs::read_dir(current_dir).ok()?;
40 let mut dir_entries: Vec<_> = entries.flatten().filter(|e| e.path().is_dir()).collect();
41 dir_entries.sort_by_key(|e| e.file_name());
42
43 for entry in dir_entries {
44 let name = entry.file_name().to_string_lossy().to_string();
45 let encoded = encode_path_for_claude(&name);
46
47 if encoded == remaining_encoded {
49 let candidate = entry.path().to_string_lossy().to_string();
50 let candidate_encoded = encode_path_for_claude(&candidate);
52 if candidate_encoded.strip_prefix('-') == Some(full_target) {
53 return Some(candidate);
54 }
55 }
56
57 if remaining_encoded.starts_with(&encoded) {
59 let after = &remaining_encoded[encoded.len()..];
60 if let Some(rest) = after.strip_prefix('-') {
61 if let Some(result) = walk_fs_recursive(entry.path().to_str()?, rest, full_target) {
62 return Some(result);
63 }
64 }
65 }
66 }
67
68 None
69}
70
71fn find_project_dir_name(file_path: &str) -> Option<String> {
76 let path = Path::new(file_path);
77 for ancestor in path.ancestors() {
79 if let Some(parent) = ancestor.parent() {
80 if parent.ends_with(".claude/projects") {
81 return ancestor.file_name()?.to_str().map(|s| s.to_string());
82 }
83 }
84 }
85 path.parent()?.file_name()?.to_str().map(|s| s.to_string())
87}
88
89pub fn decode_project_path(file_path: &str) -> Option<String> {
94 let dir_name = find_project_dir_name(file_path)?;
95 let dir_name = dir_name.as_str();
96
97 let remaining = dir_name.strip_prefix('-').unwrap_or(dir_name);
99 if !remaining.is_empty() {
100 if let Some(found) = walk_fs_for_path("/", remaining) {
101 return Some(found);
102 }
103 }
104
105 if let Some(projects_idx) = dir_name.rfind("-projects-") {
107 let path_prefix = if dir_name.starts_with('-') {
108 &dir_name[1..projects_idx]
109 } else {
110 &dir_name[..projects_idx]
111 };
112 let path_prefix = path_prefix
113 .replace("--", "\x00")
114 .replace('-', "/")
115 .replace('\x00', "/.");
116 let project_name = &dir_name[projects_idx + 10..];
117 return Some(format!("/{}/projects/{}", path_prefix, project_name));
118 }
119
120 let stripped = dir_name.strip_prefix('-').unwrap_or(dir_name);
122 let decoded = stripped
123 .replace("--", "\x00")
124 .replace('-', "/")
125 .replace('\x00', "/.");
126 Some(format!("/{}", decoded))
127}
128
129#[cfg(test)]
131pub fn extract_project_path(file_path: &str) -> Option<String> {
132 let path = Path::new(file_path);
133 let claude_project_dir = path.parent()?;
134 let dir_name = claude_project_dir.file_name()?.to_str()?;
135
136 if let Some(projects_idx) = dir_name.rfind("-projects-") {
137 let path_prefix = if dir_name.starts_with('-') {
138 &dir_name[1..projects_idx]
139 } else {
140 &dir_name[..projects_idx]
141 };
142 let path_prefix = path_prefix
143 .replace("--", "\x00")
144 .replace('-', "/")
145 .replace('\x00', "/.");
146 let project_name = &dir_name[projects_idx + 10..];
147 let candidate = format!("/{}/projects/{}", path_prefix, project_name);
148 if Path::new(&candidate).exists() {
149 return Some(candidate);
150 }
151 }
152
153 let stripped = dir_name.strip_prefix('-').unwrap_or(dir_name);
154 let decoded = stripped
155 .replace("--", "\x00")
156 .replace('-', "/")
157 .replace('\x00', "/.");
158 let candidate = format!("/{}", decoded);
159 if Path::new(&candidate).exists() {
160 return Some(candidate);
161 }
162
163 let parts: Vec<&str> = dir_name.split('-').collect();
164 for split_point in (1..parts.len()).rev() {
165 let path_part: String = parts[..split_point].join("/");
166 let name_part: String = parts[split_point..].join("-");
167
168 let candidate = if path_part.starts_with('/') {
169 format!("{}/{}", path_part, name_part)
170 } else {
171 format!("/{}/{}", path_part, name_part)
172 };
173
174 let candidate = candidate.replace("//", "/.");
175
176 if Path::new(&candidate).exists() {
177 return Some(candidate);
178 }
179 }
180
181 None
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use tempfile::TempDir;
188
189 #[test]
190 fn test_encode_path_simple() {
191 assert_eq!(encode_path_for_claude("/Users/user"), "-Users-user");
192 }
193
194 #[test]
195 fn test_encode_path_hidden_dir() {
196 assert_eq!(
197 encode_path_for_claude("/Users/user/.claude"),
198 "-Users-user--claude"
199 );
200 }
201
202 #[test]
203 fn test_encode_path_spaces_and_parens() {
204 assert_eq!(
205 encode_path_for_claude("/Users/user/Downloads/dc-vpn (1)"),
206 "-Users-user-Downloads-dc-vpn--1-"
207 );
208 }
209
210 #[test]
211 fn test_encode_path_underscores() {
212 assert_eq!(
213 encode_path_for_claude("/Users/user/my_project"),
214 "-Users-user-my-project"
215 );
216 }
217
218 #[test]
219 fn test_walk_fs_simple() {
220 let dir = TempDir::new().unwrap();
221 let sub = dir.path().join("alpha").join("beta");
222 fs::create_dir_all(&sub).unwrap();
223
224 let encoded = encode_path_for_claude(dir.path().to_str().unwrap());
225 let remaining = encoded.strip_prefix('-').unwrap();
226 let result = walk_fs_for_path("/", remaining);
227 assert_eq!(result, Some(dir.path().to_string_lossy().to_string()));
228
229 let full_encoded = encode_path_for_claude(sub.to_str().unwrap());
230 let remaining = full_encoded.strip_prefix('-').unwrap();
231 let result = walk_fs_for_path("/", remaining);
232 assert_eq!(result, Some(sub.to_string_lossy().to_string()));
233 }
234
235 #[test]
236 fn test_walk_fs_special_chars() {
237 let dir = TempDir::new().unwrap();
238 let special = dir.path().join("my dir (2)");
239 fs::create_dir_all(&special).unwrap();
240
241 let full_encoded = encode_path_for_claude(special.to_str().unwrap());
242 let remaining = full_encoded.strip_prefix('-').unwrap();
243 let result = walk_fs_for_path("/", remaining);
244 assert_eq!(result, Some(special.to_string_lossy().to_string()));
245 }
246
247 #[test]
248 fn test_walk_fs_dash_ambiguity() {
249 let dir = TempDir::new().unwrap();
250 let dashed = dir.path().join("a-b");
251 let nested = dir.path().join("a").join("b");
252 fs::create_dir_all(&dashed).unwrap();
253 fs::create_dir_all(&nested).unwrap();
254
255 let encoded = encode_path_for_claude(dashed.to_str().unwrap());
256 let remaining = encoded.strip_prefix('-').unwrap();
257 let result = walk_fs_for_path("/", remaining);
258 assert!(result.is_some());
259 let found = result.unwrap();
260 assert!(found == dashed.to_string_lossy() || found == nested.to_string_lossy());
261 }
262
263 #[test]
264 fn test_find_project_dir_name_regular_session() {
265 let path = "/Users/user/.claude/projects/-Users-user-projects-myapp/session.jsonl";
266 assert_eq!(
267 find_project_dir_name(path),
268 Some("-Users-user-projects-myapp".to_string())
269 );
270 }
271
272 #[test]
273 fn test_find_project_dir_name_subagent_session() {
274 let path = "/Users/user/.claude/projects/-Users-user-projects-myapp/abc-123/subagents/agent-xyz.jsonl";
275 assert_eq!(
276 find_project_dir_name(path),
277 Some("-Users-user-projects-myapp".to_string())
278 );
279 }
280
281 #[test]
282 fn test_decode_project_path_subagent_session() {
283 let file_path = "/fake/.claude/projects/-Users-user-projects-myapp/dffd4ad8-c1bc-459d/subagents/agent-a65efef89f277db1a.jsonl";
284 let result = decode_project_path(file_path);
285 assert_eq!(result, Some("/Users/user/projects/myapp".to_string()));
286 }
287
288 #[test]
289 fn test_decode_project_path_nonexistent_falls_back() {
290 let file_path = "/Users/user/.claude/projects/-Users-user-projects-myapp/session.jsonl";
291 let result = decode_project_path(file_path);
292 assert_eq!(result, Some("/Users/user/projects/myapp".to_string()));
293 }
294
295 #[test]
296 fn test_decode_project_path_with_projects_marker() {
297 let file_path = "/fake/.claude/projects/-Users-user-projects-myapp/session.jsonl";
298 let result = decode_project_path(file_path);
299 assert_eq!(result, Some("/Users/user/projects/myapp".to_string()));
300 }
301
302 #[test]
303 fn test_decode_project_path_hidden_dir_fallback() {
304 let file_path = "/fake/.claude/projects/-Users-user--claude/session.jsonl";
305 let result = decode_project_path(file_path);
306 assert_eq!(result, Some("/Users/user/.claude".to_string()));
307 }
308
309 #[test]
310 fn test_decode_roundtrip_with_tempdir() {
311 let dir = TempDir::new().unwrap();
312 let project = dir.path().join("my project (v2)");
313 fs::create_dir_all(&project).unwrap();
314
315 let encoded_name = encode_path_for_claude(project.to_str().unwrap());
316 let remaining = encoded_name.strip_prefix('-').unwrap();
317 let result = walk_fs_for_path("/", remaining);
318 assert_eq!(result, Some(project.to_string_lossy().to_string()));
319 }
320
321 #[test]
322 fn test_extract_project_path_with_projects_marker() {
323 let dir = TempDir::new().unwrap();
324 let project = dir.path().join("projects").join("myapp");
325 fs::create_dir_all(&project).unwrap();
326
327 let encoded = encode_path_for_claude(project.to_str().unwrap());
328 let file_path = format!("/fake/.claude/projects/{}/session.jsonl", encoded);
329 let result = extract_project_path(&file_path);
330 assert_eq!(result, Some(project.to_string_lossy().to_string()));
331 }
332
333 #[test]
334 fn test_extract_project_path_simple_dir() {
335 let dir = TempDir::new().unwrap();
336 let encoded = encode_path_for_claude(dir.path().to_str().unwrap());
337 let file_path = format!("/fake/.claude/projects/{}/session.jsonl", encoded);
338 let result = extract_project_path(&file_path);
339 assert_eq!(result, Some(dir.path().to_string_lossy().to_string()));
340 }
341
342 #[test]
343 fn test_extract_project_path_nonexistent_returns_none() {
344 let file_path = "/fake/.claude/projects/-nonexistent-path-12345/session.jsonl";
345 let result = extract_project_path(file_path);
346 assert!(result.is_none() || Path::new(&result.unwrap()).exists());
347 }
348}