Skip to main content

ccs/resume/
path_codec.rs

1use std::fs;
2use std::path::Path;
3
4/// Encode a path the same way Claude CLI does: replace any non-ASCII-alphanumeric
5/// character (except `-`) with `-`.
6pub 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
18/// Walk the filesystem to find the original directory whose Claude-encoded form
19/// matches `remaining_encoded` (the full encoded directory name without leading `-`).
20/// `full_target` is the original complete encoded name for round-trip validation.
21pub 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        // Validate: encoding this path must produce the original target
32        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        // Exact match: this is the last path component
48        if encoded == remaining_encoded {
49            let candidate = entry.path().to_string_lossy().to_string();
50            // Round-trip validation
51            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        // Prefix match: more components follow after a `-` separator (which represents `/`)
58        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
71/// Find the Claude project directory from a session file path.
72/// For regular sessions: `.claude/projects/<encoded-dir>/session.jsonl`
73/// For subagent sessions: `.claude/projects/<encoded-dir>/<session-id>/subagents/agent.jsonl`
74/// Returns the `<encoded-dir>` component name.
75fn find_project_dir_name(file_path: &str) -> Option<String> {
76    let path = Path::new(file_path);
77    // Walk up ancestors to find the directory directly under .claude/projects/
78    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    // Fallback: use immediate parent (original behavior)
86    path.parent()?.file_name()?.to_str().map(|s| s.to_string())
87}
88
89/// Decode the original project path from the .claude/projects folder name.
90/// First tries walking the filesystem to find the exact directory (handles ambiguous
91/// encodings like spaces, parentheses, dots all becoming `-`).
92/// Falls back to naive string-based decoding.
93pub 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    // Strategy 1: Walk the filesystem to find the exact matching path.
98    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    // Strategy 2: If there's a "-projects-" marker, use it
106    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    // Strategy 3: Just convert dashes to slashes (handle -- as /. for hidden dirs)
121    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/// Extract the actual project path from the .claude/projects path (test helper).
130#[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}