File size: 11,748 Bytes
37c815b
 
 
 
 
 
 
 
 
 
 
6f3bc03
37c815b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d19f32
37c815b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f3bc03
37c815b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7984228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""Tests for _decode_project_path and _greedy_path_decode (issue #47).

Directory names that contain dots (e.g. ``GitHub.nosync``) or multiple
hyphens (e.g. ``my-cool-project``) were silently dropped because
_greedy_path_decode only tried joining two consecutive tokens with a hyphen,
making it impossible to reconstruct names formed from three or more tokens.
"""

from __future__ import annotations

from pathlib import Path
from uuid import uuid4

import pytest

from headroom.learn.scanner import _decode_project_path, _greedy_path_decode

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_dirs(base: Path, *rel_paths: str) -> None:
    """Create one or more relative directory paths under *base*."""
    for rel in rel_paths:
        (base / rel).mkdir(parents=True, exist_ok=True)


# ---------------------------------------------------------------------------
# _greedy_path_decode
# ---------------------------------------------------------------------------


class TestGreedyPathDecode:
    """Unit tests for _greedy_path_decode."""

    def test_simple_directory(self, tmp_path: Path) -> None:
        _make_dirs(tmp_path, "headroom")
        result = _greedy_path_decode(tmp_path, ["headroom"])
        assert result == tmp_path / "headroom"

    def test_single_hyphen_in_dirname(self, tmp_path: Path) -> None:
        """Directory name contains one literal hyphen."""
        _make_dirs(tmp_path, "my-project")
        result = _greedy_path_decode(tmp_path, ["my", "project"])
        assert result == tmp_path / "my-project"

    def test_multiple_hyphens_in_dirname(self, tmp_path: Path) -> None:
        """Directory name contains multiple literal hyphens (the regression case)."""
        _make_dirs(tmp_path, "my-cool-project")
        result = _greedy_path_decode(tmp_path, ["my", "cool", "project"])
        assert result == tmp_path / "my-cool-project"

    def test_dot_only_in_dirname(self, tmp_path: Path) -> None:
        """Directory name contains a dot but no hyphen (e.g. GitHub.nosync)."""
        _make_dirs(tmp_path, "GitHub.nosync")
        result = _greedy_path_decode(tmp_path, ["GitHub.nosync"])
        assert result == tmp_path / "GitHub.nosync"

    def test_dot_and_single_hyphen_in_dirname(self, tmp_path: Path) -> None:
        """Directory name has both a dot and a single hyphen (e.g. my-project.nosync)."""
        _make_dirs(tmp_path, "my-project.nosync")
        result = _greedy_path_decode(tmp_path, ["my", "project.nosync"])
        assert result == tmp_path / "my-project.nosync"

    def test_dot_and_multiple_hyphens_in_dirname(self, tmp_path: Path) -> None:
        """Directory name has a dot and multiple hyphens (e.g. my-cool-project.nosync).

        This was the primary regression: the old code only joined pairs, so it
        could never reconstruct a three-token hyphenated name.
        """
        _make_dirs(tmp_path, "my-cool-project.nosync")
        result = _greedy_path_decode(tmp_path, ["my", "cool", "project.nosync"])
        assert result == tmp_path / "my-cool-project.nosync"

    def test_dot_dir_containing_hyphenated_subdir(self, tmp_path: Path) -> None:
        """Path like GitHub.nosync/my-project β€” dot parent + hyphen child."""
        _make_dirs(tmp_path, "GitHub.nosync/my-project")
        result = _greedy_path_decode(tmp_path, ["GitHub.nosync", "my", "project"])
        assert result == tmp_path / "GitHub.nosync" / "my-project"

    def test_dot_dir_with_multi_hyphen_subdir(self, tmp_path: Path) -> None:
        """Path like GitHub.nosync/my-cool-app β€” dot parent + multi-hyphen child."""
        _make_dirs(tmp_path, "GitHub.nosync/my-cool-app")
        result = _greedy_path_decode(tmp_path, ["GitHub.nosync", "my", "cool", "app"])
        assert result == tmp_path / "GitHub.nosync" / "my-cool-app"

    def test_multi_hyphen_dot_dir_containing_subproject(self, tmp_path: Path) -> None:
        """Path like my-cool-project.nosync/headroom β€” hardest combination."""
        _make_dirs(tmp_path, "my-cool-project.nosync/headroom")
        result = _greedy_path_decode(tmp_path, ["my", "cool", "project.nosync", "headroom"])
        assert result == tmp_path / "my-cool-project.nosync" / "headroom"

    def test_dot_flattened_into_separate_tokens(self, tmp_path: Path) -> None:
        """Flattened encoding like GitHub-nosync should map back to GitHub.nosync."""
        _make_dirs(tmp_path, "GitHub.nosync/thebest")
        result = _greedy_path_decode(tmp_path, ["GitHub", "nosync", "thebest"])
        assert result == tmp_path / "GitHub.nosync" / "thebest"

    def test_hybrid_hyphen_and_dot_flattening(self, tmp_path: Path) -> None:
        """Flattened encoding should reconstruct mixed separators in one component."""
        _make_dirs(tmp_path, "my-cool-project.nosync/headroom")
        result = _greedy_path_decode(tmp_path, ["my", "cool", "project", "nosync", "headroom"])
        assert result == tmp_path / "my-cool-project.nosync" / "headroom"

    def test_nonexistent_path_returns_none(self, tmp_path: Path) -> None:
        result = _greedy_path_decode(tmp_path, ["does", "not", "exist"])
        assert result is None

    def test_empty_parts_returns_base_when_exists(self, tmp_path: Path) -> None:
        result = _greedy_path_decode(tmp_path, [])
        assert result == tmp_path

    def test_empty_parts_returns_none_when_not_exists(self) -> None:
        result = _greedy_path_decode(Path("/nonexistent/path"), [])
        assert result is None


# ---------------------------------------------------------------------------
# _decode_project_path
# ---------------------------------------------------------------------------


class TestDecodeProjectPath:
    """Integration-level tests for _decode_project_path.

    Note: _decode_project_path's greedy branch only activates for paths whose
    first component is ``Users`` (the common macOS home prefix).  Tests that
    exercise the greedy decoder therefore synthesise an encoded name rooted at
    ``/Users/<username>/…`` inside a real temporary directory created under
    that prefix.  When the temp directory does not exist under ``/Users`` the
    tests fall back to ``/tmp`` and rely only on the fast simple-replace path.
    """

    def test_returns_none_for_non_absolute_encoded_name(self) -> None:
        assert _decode_project_path("Users-foo-bar") is None

    def test_simple_replace_finds_dot_path(self, users_tmp: Path) -> None:
        """Simple replace-all works when no dir names contain hyphens.

        The encoded name maps directly to the real path because every ``-`` is
        a path separator; dots in directory names are preserved unchanged.
        """
        project = users_tmp / "GitHub.nosync" / "headroom"
        project.mkdir(parents=True)
        # Build the encoded name exactly as Claude Code does (/  β†’  -)
        encoded = "-" + str(project)[1:].replace("/", "-")
        result = _decode_project_path(encoded)
        if str(users_tmp).startswith("/Users/"):
            assert result == project
        else:
            assert result is None or result == project

    # ------------------------------------------------------------------
    # Greedy-decoder tests β€” require a /Users-rooted path to activate.
    # We try to create a temp dir under the real /Users tree; if that is
    # not writable we skip rather than fail (CI typically runs as a real
    # macOS user whose home IS under /Users).
    # ------------------------------------------------------------------

    @pytest.fixture()
    def users_tmp(self, tmp_path: Path) -> Path:
        """Return a temporary directory whose path starts with /Users/…

        On macOS the system temp dir is under /private/var, so we create a
        disposable directory directly inside the real user's home instead.
        Falls back to tmp_path so tests still run on non-macOS platforms
        (where the greedy branch isn't reached but no crash occurs either).
        """

        home = Path.home()
        if str(home).startswith("/Users/"):
            base = home / ".pytest_headroom_tmp"
            try:
                base.mkdir(exist_ok=True)
            except PermissionError:
                pytest.skip("Cannot create /Users-rooted temp dir in this environment")
            # Use a sub-directory unique to this test invocation
            unique = base / uuid4().hex
            try:
                unique.mkdir()
            except PermissionError:
                pytest.skip("Cannot create /Users-rooted temp dir in this environment")
            yield unique
            import shutil

            shutil.rmtree(unique, ignore_errors=True)
        else:
            yield tmp_path

    def test_dot_and_hyphen_in_dirname_via_greedy(self, users_tmp: Path) -> None:
        """GitHub.nosync/my-project β€” dot parent + hyphenated child (issue #47).

        Simple replace-all gives ``…/GitHub.nosync/my/project`` which does not
        exist, so the greedy decoder must reconstruct ``my-project``.
        """
        project = users_tmp / "GitHub.nosync" / "my-project"
        project.mkdir(parents=True)

        encoded = "-" + str(project)[1:].replace("/", "-")
        result = _decode_project_path(encoded)

        if str(users_tmp).startswith("/Users/"):
            assert result == project
        else:
            # Greedy branch not reached outside /Users; just confirm no crash
            assert result is None or result == project

    def test_multi_hyphen_dot_dirname_via_greedy(self, users_tmp: Path) -> None:
        """my-cool-project.nosync/app β€” primary regression from issue #47.

        Three tokens joined by hyphens form the parent dir name; the old code
        only tried pairs and therefore could never reconstruct this component.
        """
        project = users_tmp / "my-cool-project.nosync" / "app"
        project.mkdir(parents=True)

        encoded = "-" + str(project)[1:].replace("/", "-")
        result = _decode_project_path(encoded)

        if str(users_tmp).startswith("/Users/"):
            assert result == project
        else:
            assert result is None or result == project

    def test_flattened_dot_dirname_via_greedy(self, users_tmp: Path) -> None:
        """GitHub.nosync/thebest should decode from GitHub-nosync-thebest."""
        project = users_tmp / "GitHub.nosync" / "thebest"
        project.mkdir(parents=True)

        encoded = "-" + str(project)[1:].replace("/", "-").replace(".", "-")
        result = _decode_project_path(encoded)

        if str(users_tmp).startswith("/Users/"):
            assert result == project
        else:
            assert result is None or result == project

    def test_windows_drive_letter_pattern(self) -> None:
        """Encoded name -C-MQ2-macros should detect Windows drive letter."""
        import sys

        result = _decode_project_path("-C-MQ2-macros")
        if sys.platform == "win32":
            # On Windows: tries C:\\MQ2\\macros, may or may not exist
            assert result is None or str(result).startswith("C:")
        else:
            # On Unix: drive detection runs but path doesn't exist β†’ falls through
            # Then Unix paths tried β†’ also don't exist β†’ returns None
            assert result is None

    def test_windows_users_path(self) -> None:
        """Encoded name -C-Users-foo-project detects drive letter."""
        import sys

        result = _decode_project_path("-C-Users-foo-project")
        if sys.platform == "win32":
            assert result is None or "Users" in str(result)
        else:
            assert result is None