diff --git a/headroom/learn/plugins/claude.py b/headroom/learn/plugins/claude.py index 2753c8c20..79d1b331d 100644 --- a/headroom/learn/plugins/claude.py +++ b/headroom/learn/plugins/claude.py @@ -70,12 +70,14 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner): project_path = _decode_project_path(entry.name) if project_path is None: - fallback_parts = entry.name[1:].split("-") - if len(fallback_parts[0]) == 1 and fallback_parts[0].isalpha(): - drive = fallback_parts[0].upper() - project_path = Path(f"{drive}:\\" + "\\".join(fallback_parts[1:])) + win = re.match(r"^-?([A-Za-z])--?(.+)$", entry.name) + if win: + drive = win.group(1).upper() + tokens = [p for p in win.group(2).split("-") if p] + project_path = Path(f"{drive}:\\" + "\\".join(tokens)) else: - project_path = Path("/" + entry.name[1:].replace("-", "/")) + stripped = entry.name.lstrip("-") + project_path = Path("/" + stripped.replace("-", "/")) name = _project_display_name(project_path, entry.name) @@ -335,8 +337,41 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner): # ============================================================================= +def _decode_windows_path(drive: str, parts: list[str]) -> Path | None: + """Reconstruct a Windows path from drive letter + dash-split tokens. + + Empty tokens (from consecutive dashes in the encoded name) are dropped so + the literal join never produces doubled separators. + """ + tokens = [p for p in parts if p] + if not tokens: + return None + win_path = Path(f"{drive}:\\" + "\\".join(tokens)) + if win_path.exists(): + return win_path + drive_root = Path(f"{drive}:\\") + if drive_root.exists(): + result = _greedy_path_decode(drive_root, tokens) + if result: + return result + if tokens[0].lower() == "users": + return win_path + return None + + def _decode_project_path(escaped_name: str) -> Path | None: """Decode a Claude Code escaped project path.""" + # Windows paths are encoded without a leading dash: "C:\Users\x" becomes + # "C--Users-x" (":" and "\" each collapse to "-"). Older callers also pass + # the legacy "-C-Users-x" form; accept both. + win = re.match(r"^-?([A-Za-z])--?(.+)$", escaped_name) + if win: + result = _decode_windows_path(win.group(1).upper(), win.group(2).split("-")) + if result is not None: + return result + if not escaped_name.startswith("-"): + return None + if not escaped_name.startswith("-"): return None @@ -344,19 +379,6 @@ def _decode_project_path(escaped_name: str) -> Path | None: if len(parts) < 2: return None - if len(parts[0]) == 1 and parts[0].isalpha(): - drive = parts[0].upper() - win_path = Path(f"{drive}:\\" + "\\".join(parts[1:])) - if win_path.exists(): - return win_path - win_base = Path(f"{drive}:\\{parts[1]}") if len(parts) > 1 else win_path - if win_base.exists() and len(parts) > 2: - result = _greedy_path_decode(win_base, parts[2:]) - if result: - return result - if len(parts) > 1 and parts[1].lower() == "users": - return win_path - simple = Path("/" + escaped_name[1:].replace("-", "/")) if simple.exists(): return simple diff --git a/tests/test_learn/test_scanner.py b/tests/test_learn/test_scanner.py index 295c6fe59..2e5aafd04 100644 --- a/tests/test_learn/test_scanner.py +++ b/tests/test_learn/test_scanner.py @@ -391,6 +391,56 @@ class TestDecodeProjectPath: assert projects[0].name == "work" assert str(projects[0].project_path).startswith("C:") + def test_windows_double_dash_encoding_decodes(self) -> None: + """Real Claude Code encoding has no leading dash: C:\\Users\\x → C--Users-x (#1849). + + The drive colon and first backslash each flatten to '-', producing a + double dash after the drive letter. The decoder must not emit doubled + path separators from the resulting empty split token. + """ + result = _decode_project_path("C--Users-jane-proj") + + assert result is not None + rendered = str(result) + assert rendered.startswith("C:") + assert "\\\\" not in rendered.removeprefix("C:") + assert rendered == "C:\\Users\\jane\\proj" + + def test_windows_double_dash_dotted_username_via_greedy(self) -> None: + """C--...-first-last-... must rejoin 'first.last' when the dir exists (#1849).""" + import sys + import tempfile + + if sys.platform != "win32": + pytest.skip("greedy Windows-path decode requires real Windows filesystem") + + with tempfile.TemporaryDirectory() as td: + project = Path(td) / "john.doe" / "work" + project.mkdir(parents=True) + + drive = Path(td).drive[0] + rest = str(project)[3:] # strip 'C:\\' + encoded = f"{drive}--" + rest.replace("\\", "-").replace(".", "-").replace(" ", "-") + + result = _decode_project_path(encoded) + assert result == project + + def test_discover_double_dash_windows_project_fallback(self, tmp_path: Path) -> None: + """Nonexistent C--Users-... project must fall back to a valid path, not \\\\\\Users (#1849).""" + claude_dir = tmp_path / ".claude" + project_dir = claude_dir / "projects" / "C--Users-jane-proj" + project_dir.mkdir(parents=True) + (project_dir / "session.jsonl").write_text("{}\n") + + projects = ClaudeCodeScanner(claude_dir=claude_dir).discover_projects() + + assert len(projects) == 1 + assert projects[0].name == "proj" + rendered = str(projects[0].project_path) + assert rendered.startswith("C:") + assert "\\\\" not in rendered.removeprefix("C:") + assert not rendered.startswith("\\") + def test_home_dir_username_stays_single_component(self) -> None: """A home-directory name must survive decoding as one component.