diff --git a/headroom/learn/plugins/claude.py b/headroom/learn/plugins/claude.py index 2be4f9e7e..37f7e3434 100644 --- a/headroom/learn/plugins/claude.py +++ b/headroom/learn/plugins/claude.py @@ -397,9 +397,9 @@ def _component_tokenizations(component: str) -> list[list[str]]: add([component]) - for separator in ("-", ".", "_", None): + for separator in (" ", "-", ".", "_", None): if separator is None: - tokens = [token for token in re.split(r"[-._]", component) if token] + tokens = [token for token in re.split(r"[-.\s_]", component) if token] else: tokens = [token for token in component.split(separator) if token] add(tokens) @@ -407,9 +407,9 @@ def _component_tokenizations(component: str) -> list[list[str]]: if component.startswith(".") and len(component) > 1: hidden_component = component[1:] add(["", hidden_component]) - for separator in ("-", ".", "_", None): + for separator in (" ", "-", ".", "_", None): if separator is None: - tokens = [token for token in re.split(r"[-._]", hidden_component) if token] + tokens = [token for token in re.split(r"[-.\s_]", hidden_component) if token] else: tokens = [token for token in hidden_component.split(separator) if token] add(["", *tokens]) diff --git a/tests/test_learn/test_scanner.py b/tests/test_learn/test_scanner.py index 6e0efbeb8..0f6dcb9f1 100644 --- a/tests/test_learn/test_scanner.py +++ b/tests/test_learn/test_scanner.py @@ -104,6 +104,26 @@ class TestGreedyPathDecode: result = _greedy_path_decode(tmp_path, ["my", "cool", "project", "nosync", "headroom"]) assert result == tmp_path / "my-cool-project.nosync" / "headroom" + # ---- Space tests (issue #997) ---- + + def test_single_space_in_dirname(self, tmp_path: Path) -> None: + """Directory name contains a space (e.g. 'Claude Projects').""" + _make_dirs(tmp_path, "Claude Projects") + result = _greedy_path_decode(tmp_path, ["Claude", "Projects"]) + assert result == tmp_path / "Claude Projects" + + def test_multiple_spaces_in_dirname(self, tmp_path: Path) -> None: + """Directory name contains multiple spaces (e.g. 'Claude Code Projects').""" + _make_dirs(tmp_path, "Claude Code Projects") + result = _greedy_path_decode(tmp_path, ["Claude", "Code", "Projects"]) + assert result == tmp_path / "Claude Code Projects" + + def test_space_nested_path(self, tmp_path: Path) -> None: + """Nested path like Desktop/'Claude Code Projects' should decode correctly.""" + _make_dirs(tmp_path, "Desktop/Claude Code Projects") + result = _greedy_path_decode(tmp_path, ["Desktop", "Claude", "Code", "Projects"]) + assert result == tmp_path / "Desktop" / "Claude Code Projects" + # ---- Underscore tests (issue #159) ---- def test_single_underscore_in_dirname(self, tmp_path: Path) -> None: @@ -332,6 +352,32 @@ class TestDecodeProjectPath: assert "john\\doe" not in rendered assert "john/doe" not in rendered + def test_windows_path_with_spaces_decoded_via_greedy(self) -> None: + """Spaces in Windows dir names must not split into separate components (#997). + + Claude Code encodes 'C:\\Users\\user\\Desktop\\Claude Code Projects' as + '-C-Users-user-Desktop-Claude-Code-Projects'. The greedy decoder must + reconstruct 'Claude Code Projects' as a single directory. + """ + import sys + import tempfile + + if sys.platform != "win32": + pytest.skip("greedy Windows-path decode requires real Windows filesystem") + + with tempfile.TemporaryDirectory() as td: + space_dir = Path(td) / "Claude Code Projects" + space_dir.mkdir() + + drive = Path(td).drive[0] + rest = str(Path(td))[3:] # strip 'C:\\' + rest_parts = rest.replace("\\", "-").replace(" ", "-") + encoded = f"-{drive}-{rest_parts}-Claude-Code-Projects" + + result = _decode_project_path(encoded) + assert result is not None + assert result == space_dir + def test_discover_windows_project_uses_leaf_name(self, tmp_path: Path) -> None: """A syntactic Windows path decoded on Unix should still display the project leaf.""" claude_dir = tmp_path / ".claude"