llm-tool-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_tool_cli/__init__.py +0 -0
- llm_tool_cli/cli/__init__.py +1 -0
- llm_tool_cli/cli/application.py +31 -0
- llm_tool_cli/cli/commands/__init__.py +0 -0
- llm_tool_cli/cli/commands/initialization.py +36 -0
- llm_tool_cli/cli/commands/skills.py +25 -0
- llm_tool_cli/cli/commands/tests/__init__.py +0 -0
- llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
- llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
- llm_tool_cli/cli/commands/tests/test_version.py +98 -0
- llm_tool_cli/cli/commands/version.py +17 -0
- llm_tool_cli/cli/context.py +39 -0
- llm_tool_cli/cli/entities.py +3 -0
- llm_tool_cli/cli/errors.py +15 -0
- llm_tool_cli/cli/handling.py +41 -0
- llm_tool_cli/cli/options.py +48 -0
- llm_tool_cli/cli/tests/__init__.py +0 -0
- llm_tool_cli/cli/tests/test_application.py +167 -0
- llm_tool_cli/cli/tests/test_context.py +179 -0
- llm_tool_cli/cli/tests/test_handling.py +230 -0
- llm_tool_cli/cli/tests/test_options.py +125 -0
- llm_tool_cli/config/__init__.py +33 -0
- llm_tool_cli/config/errors.py +92 -0
- llm_tool_cli/config/fields.py +39 -0
- llm_tool_cli/config/files.py +175 -0
- llm_tool_cli/config/project.py +87 -0
- llm_tool_cli/config/runtime.py +14 -0
- llm_tool_cli/config/tests/__init__.py +0 -0
- llm_tool_cli/config/tests/fixtures.py +34 -0
- llm_tool_cli/config/tests/test_fields.py +124 -0
- llm_tool_cli/config/tests/test_files.py +826 -0
- llm_tool_cli/config/tests/test_project.py +257 -0
- llm_tool_cli/core/__init__.py +1 -0
- llm_tool_cli/core/context.py +50 -0
- llm_tool_cli/core/entities.py +35 -0
- llm_tool_cli/core/errors.py +97 -0
- llm_tool_cli/core/result.py +118 -0
- llm_tool_cli/core/settings.py +37 -0
- llm_tool_cli/core/tests/__init__.py +0 -0
- llm_tool_cli/core/tests/fixtures.py +13 -0
- llm_tool_cli/core/tests/test_context.py +152 -0
- llm_tool_cli/core/tests/test_entities.py +65 -0
- llm_tool_cli/core/tests/test_errors.py +211 -0
- llm_tool_cli/core/tests/test_result.py +256 -0
- llm_tool_cli/core/tests/test_settings.py +64 -0
- llm_tool_cli/output/__init__.py +3 -0
- llm_tool_cli/output/emitter.py +108 -0
- llm_tool_cli/output/errors.py +21 -0
- llm_tool_cli/output/journal.py +66 -0
- llm_tool_cli/output/tests/__init__.py +0 -0
- llm_tool_cli/output/tests/helpers.py +29 -0
- llm_tool_cli/output/tests/test_emitter.py +406 -0
- llm_tool_cli/output/tests/test_journal.py +141 -0
- llm_tool_cli/paths/__init__.py +51 -0
- llm_tool_cli/paths/enumeration.py +72 -0
- llm_tool_cli/paths/errors.py +42 -0
- llm_tool_cli/paths/filesystem.py +164 -0
- llm_tool_cli/paths/globs.py +124 -0
- llm_tool_cli/paths/normalization.py +58 -0
- llm_tool_cli/paths/tests/__init__.py +0 -0
- llm_tool_cli/paths/tests/test_enumeration.py +229 -0
- llm_tool_cli/paths/tests/test_filesystem.py +778 -0
- llm_tool_cli/paths/tests/test_globs.py +137 -0
- llm_tool_cli/paths/tests/test_normalization.py +163 -0
- llm_tool_cli/protocol/__init__.py +4 -0
- llm_tool_cli/protocol/cell_shortcuts.py +39 -0
- llm_tool_cli/protocol/entities.py +7 -0
- llm_tool_cli/protocol/errors.py +21 -0
- llm_tool_cli/protocol/journal.py +21 -0
- llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
- llm_tool_cli/protocol/logic_cells/base.py +41 -0
- llm_tool_cli/protocol/logic_cells/content.py +26 -0
- llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
- llm_tool_cli/protocol/logic_cells/journal.py +44 -0
- llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
- llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
- llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
- llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
- llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
- llm_tool_cli/protocol/logic_cells/warning.py +22 -0
- llm_tool_cli/protocol/output_cells/__init__.py +11 -0
- llm_tool_cli/protocol/output_cells/automation.py +14 -0
- llm_tool_cli/protocol/output_cells/base.py +65 -0
- llm_tool_cli/protocol/output_cells/human.py +24 -0
- llm_tool_cli/protocol/output_cells/journal.py +23 -0
- llm_tool_cli/protocol/output_cells/llm.py +25 -0
- llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
- llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
- llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
- llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
- llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
- llm_tool_cli/protocol/serialization.py +7 -0
- llm_tool_cli/protocol/tests/__init__.py +0 -0
- llm_tool_cli/protocol/tests/helpers.py +25 -0
- llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
- llm_tool_cli/protocol/tests/test_journal.py +44 -0
- llm_tool_cli/protocol/tests/test_serialization.py +33 -0
- llm_tool_cli/py.typed +0 -0
- llm_tool_cli/skills/__init__.py +6 -0
- llm_tool_cli/skills/errors.py +12 -0
- llm_tool_cli/skills/fixtures.py +14 -0
- llm_tool_cli/skills/tests/__init__.py +0 -0
- llm_tool_cli/skills/tests/test_fixtures.py +58 -0
- llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
- llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
- llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
- llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pytest_mock import MockerFixture
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.paths import (
|
|
7
|
+
ProjectRootPath,
|
|
8
|
+
normalize_path,
|
|
9
|
+
project_path_id_from_filesystem,
|
|
10
|
+
project_path_id_from_resolved,
|
|
11
|
+
resolve_inside_project,
|
|
12
|
+
resolve_project_path,
|
|
13
|
+
resolve_project_root,
|
|
14
|
+
resolve_root_anchored_path,
|
|
15
|
+
)
|
|
16
|
+
from llm_tool_cli.paths.errors import InvalidProjectPath, PathResolutionFailed
|
|
17
|
+
from llm_tool_cli.paths.filesystem import _project_path_input, _validate_no_symlinks
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestProjectPathInput:
|
|
21
|
+
@pytest.mark.parametrize("value", ["@/link/file", "link/file"])
|
|
22
|
+
def test_leaves_target_resolution_to_caller(self, tmp_path: Path, value: str) -> None:
|
|
23
|
+
(tmp_path / "link").symlink_to(tmp_path)
|
|
24
|
+
|
|
25
|
+
assert _project_path_input(value, ProjectRootPath(tmp_path)).unwrap() == tmp_path / "link/file"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestResolveProjectPath:
|
|
29
|
+
@pytest.mark.parametrize("kind", ["file", "directory", "missing"])
|
|
30
|
+
@pytest.mark.parametrize("value", ["@/nested/target", "./nested/target"])
|
|
31
|
+
def test_resolves_without_target_kind_requirements(self, tmp_path: Path, kind: str, value: str) -> None:
|
|
32
|
+
target = tmp_path / "nested" / "target"
|
|
33
|
+
target.parent.mkdir()
|
|
34
|
+
if kind == "file":
|
|
35
|
+
target.touch()
|
|
36
|
+
elif kind == "directory":
|
|
37
|
+
target.mkdir()
|
|
38
|
+
|
|
39
|
+
assert resolve_project_path(value, tmp_path).unwrap() == target
|
|
40
|
+
|
|
41
|
+
@pytest.mark.parametrize("value", ["@/file", "file"])
|
|
42
|
+
def test_relative_root_uses_cwd_and_input_uses_root(
|
|
43
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str
|
|
44
|
+
) -> None:
|
|
45
|
+
monkeypatch.chdir(tmp_path)
|
|
46
|
+
|
|
47
|
+
assert resolve_project_path(value, Path("project")).unwrap() == tmp_path / "project" / "file"
|
|
48
|
+
|
|
49
|
+
def test_resolves_absolute_input(self, tmp_path: Path) -> None:
|
|
50
|
+
target = tmp_path / "file"
|
|
51
|
+
|
|
52
|
+
assert resolve_project_path(str(target), tmp_path).unwrap() == target
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("value", ["@/file", "file"])
|
|
55
|
+
def test_accepts_project_input_when_absolute_paths_disabled(self, tmp_path: Path, value: str) -> None:
|
|
56
|
+
assert resolve_project_path(value, tmp_path, allow_absolute=False).unwrap() == tmp_path / "file"
|
|
57
|
+
|
|
58
|
+
def test_rejects_absolute_input_when_disabled(self, tmp_path: Path) -> None:
|
|
59
|
+
value = str(tmp_path / "file")
|
|
60
|
+
|
|
61
|
+
assert resolve_project_path(value, tmp_path, allow_absolute=False).unwrap_err() == [
|
|
62
|
+
InvalidProjectPath(path=value)
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("value", ["@file", "@/", "@/a/..", "@/../file", "@/a//b", "@/a/"])
|
|
66
|
+
def test_preserves_invalid_identifier_diagnostic(self, tmp_path: Path, value: str) -> None:
|
|
67
|
+
assert resolve_project_path(value, tmp_path).unwrap_err() == [InvalidProjectPath(path=value)]
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("value", ["", ".", "nested/..", "../outside"])
|
|
70
|
+
def test_rejects_root_and_outside_filesystem_inputs(self, tmp_path: Path, value: str) -> None:
|
|
71
|
+
assert resolve_project_path(value, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(tmp_path / value))]
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("value", ["@/link/file", "link/file"])
|
|
74
|
+
def test_rejects_symlink_inside_project(self, tmp_path: Path, value: str) -> None:
|
|
75
|
+
target = tmp_path / "target"
|
|
76
|
+
target.mkdir()
|
|
77
|
+
(tmp_path / "link").symlink_to(target, target_is_directory=True)
|
|
78
|
+
|
|
79
|
+
assert resolve_project_path(value, tmp_path).unwrap_err() == [
|
|
80
|
+
InvalidProjectPath(path=str(tmp_path / "link" / "file"))
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("value", ["@/link/file", "link/file"])
|
|
84
|
+
def test_rejects_symlink_escape(self, tmp_path: Path, value: str) -> None:
|
|
85
|
+
link = tmp_path / "link"
|
|
86
|
+
link.symlink_to(tmp_path.parent, target_is_directory=True)
|
|
87
|
+
|
|
88
|
+
assert resolve_project_path(value, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(link / "file"))]
|
|
89
|
+
|
|
90
|
+
def test_normalizes_identifier_before_resolving_symlinks(self, tmp_path: Path) -> None:
|
|
91
|
+
(tmp_path / "outside").symlink_to(tmp_path.parent, target_is_directory=True)
|
|
92
|
+
|
|
93
|
+
assert resolve_project_path("@/outside/../file", tmp_path).unwrap() == tmp_path / "file"
|
|
94
|
+
|
|
95
|
+
def test_expands_home_marker(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
96
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
97
|
+
|
|
98
|
+
assert resolve_project_path("~/project/file", tmp_path / "project").unwrap() == tmp_path / "project/file"
|
|
99
|
+
|
|
100
|
+
def test_rejects_expanded_home_outside_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
101
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
102
|
+
|
|
103
|
+
assert resolve_project_path("~/file", tmp_path / "project").unwrap_err() == [
|
|
104
|
+
InvalidProjectPath(path=str(tmp_path / "file"))
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def test_absolute_restriction_applies_after_expansion(
|
|
108
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
109
|
+
) -> None:
|
|
110
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
111
|
+
|
|
112
|
+
assert resolve_project_path("~/file", tmp_path, allow_absolute=False).unwrap_err() == [
|
|
113
|
+
InvalidProjectPath(path="~/file")
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def test_identifier_does_not_expand_home(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
117
|
+
mocker.patch.object(Path, "expanduser", side_effect=AssertionError("unexpected home expansion"))
|
|
118
|
+
|
|
119
|
+
assert resolve_project_path("@/~/file", tmp_path).unwrap() == tmp_path / "~" / "file"
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize("value", ["@/file", "@/../file", "file", "~/file"])
|
|
122
|
+
def test_root_failure_precedes_input_processing(self, tmp_path: Path, value: str) -> None:
|
|
123
|
+
root = tmp_path / "loop"
|
|
124
|
+
root.symlink_to(root)
|
|
125
|
+
|
|
126
|
+
failure = resolve_project_path(value, root).unwrap_err()[0]
|
|
127
|
+
|
|
128
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
129
|
+
assert failure.code == "path_resolution_failed"
|
|
130
|
+
assert failure.path == str(root)
|
|
131
|
+
assert isinstance(failure.cause, (OSError, RuntimeError))
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("value", ["@/loop/file", "loop/file"])
|
|
134
|
+
def test_rejects_target_symlink_loop(self, tmp_path: Path, value: str) -> None:
|
|
135
|
+
link = tmp_path / "loop"
|
|
136
|
+
link.symlink_to(link)
|
|
137
|
+
|
|
138
|
+
failure = resolve_project_path(value, tmp_path).unwrap_err()[0]
|
|
139
|
+
|
|
140
|
+
assert failure == InvalidProjectPath(path=str(link / "file"))
|
|
141
|
+
|
|
142
|
+
@pytest.mark.parametrize("cause", [RuntimeError("unknown home"), OSError("home lookup failed")])
|
|
143
|
+
def test_returns_home_expansion_failure(self, tmp_path: Path, mocker: MockerFixture, cause: Exception) -> None:
|
|
144
|
+
mocker.patch.object(Path, "expanduser", side_effect=cause)
|
|
145
|
+
|
|
146
|
+
failures = resolve_project_path("~/file", tmp_path).unwrap_err()
|
|
147
|
+
|
|
148
|
+
assert len(failures) == 1
|
|
149
|
+
failure = failures[0]
|
|
150
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
151
|
+
assert failure.code == "path_resolution_failed"
|
|
152
|
+
assert failure.path == "~/file"
|
|
153
|
+
assert failure.reason == str(cause)
|
|
154
|
+
assert failure.cause == cause
|
|
155
|
+
assert "cause" not in failure.as_record()
|
|
156
|
+
|
|
157
|
+
def test_unexpected_exception_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
158
|
+
mocker.patch.object(Path, "expanduser", side_effect=ValueError("unexpected failure"))
|
|
159
|
+
|
|
160
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
161
|
+
resolve_project_path("file", tmp_path)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestNormalizePath:
|
|
165
|
+
@pytest.mark.parametrize("value", ["@/nested/./child/../file", "@/nested/file"])
|
|
166
|
+
def test_normalizes_identifier(self, tmp_path: Path, value: str) -> None:
|
|
167
|
+
assert normalize_path(value, tmp_path).unwrap() == "@/nested/file"
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("value", ["@file", "@/", "@/a/..", "@/../file", "@/a//b", "@/a/"])
|
|
170
|
+
def test_preserves_invalid_identifier_diagnostic(self, tmp_path: Path, value: str) -> None:
|
|
171
|
+
assert normalize_path(value, tmp_path).unwrap_err() == [InvalidProjectPath(path=value)]
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize("kind", ["file", "directory", "missing"])
|
|
174
|
+
def test_accepts_filesystem_target_without_kind_requirements(self, tmp_path: Path, kind: str) -> None:
|
|
175
|
+
target = tmp_path / "target"
|
|
176
|
+
if kind == "file":
|
|
177
|
+
target.touch()
|
|
178
|
+
elif kind == "directory":
|
|
179
|
+
target.mkdir()
|
|
180
|
+
|
|
181
|
+
assert normalize_path(str(target), tmp_path).unwrap() == "@/target"
|
|
182
|
+
|
|
183
|
+
def test_default_base_is_resolved_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
184
|
+
monkeypatch.chdir(tmp_path)
|
|
185
|
+
|
|
186
|
+
assert normalize_path("file", Path("project")).unwrap() == "@/file"
|
|
187
|
+
|
|
188
|
+
def test_explicit_base_is_independent_of_process_cwd(
|
|
189
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
190
|
+
) -> None:
|
|
191
|
+
monkeypatch.chdir(tmp_path.parent)
|
|
192
|
+
|
|
193
|
+
assert normalize_path("file", tmp_path, cwd=tmp_path / "nested").unwrap() == "@/nested/file"
|
|
194
|
+
|
|
195
|
+
def test_relative_base_uses_process_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
196
|
+
monkeypatch.chdir(tmp_path)
|
|
197
|
+
|
|
198
|
+
assert normalize_path("file", tmp_path, cwd=Path("nested")).unwrap() == "@/nested/file"
|
|
199
|
+
|
|
200
|
+
def test_absolute_input_ignores_base(self, tmp_path: Path) -> None:
|
|
201
|
+
assert normalize_path(str(tmp_path / "file"), tmp_path, cwd=tmp_path.parent).unwrap() == "@/file"
|
|
202
|
+
|
|
203
|
+
def test_identifier_checks_symlinks_independently_of_base(self, tmp_path: Path) -> None:
|
|
204
|
+
(tmp_path / "outside").symlink_to(tmp_path.parent, target_is_directory=True)
|
|
205
|
+
|
|
206
|
+
assert normalize_path("@/outside/file", tmp_path, cwd=tmp_path.parent).unwrap_err() == [
|
|
207
|
+
InvalidProjectPath(path=str(tmp_path / "outside/file"))
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
def test_filesystem_input_rejects_internal_symlink(self, tmp_path: Path) -> None:
|
|
211
|
+
target = tmp_path / "target"
|
|
212
|
+
target.mkdir()
|
|
213
|
+
(tmp_path / "link").symlink_to(target, target_is_directory=True)
|
|
214
|
+
|
|
215
|
+
assert normalize_path("link/file", tmp_path).unwrap_err() == [
|
|
216
|
+
InvalidProjectPath(path=str(tmp_path / "link/file"))
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
def test_filesystem_input_rejects_symlink_escape(self, tmp_path: Path) -> None:
|
|
220
|
+
link = tmp_path / "outside"
|
|
221
|
+
link.symlink_to(tmp_path.parent, target_is_directory=True)
|
|
222
|
+
|
|
223
|
+
assert normalize_path("outside/file", tmp_path).unwrap_err() == [InvalidProjectPath(path=str(link / "file"))]
|
|
224
|
+
|
|
225
|
+
@pytest.mark.parametrize("value", [".", "nested/..", "../outside"])
|
|
226
|
+
def test_rejects_root_and_outside_filesystem_inputs(self, tmp_path: Path, value: str) -> None:
|
|
227
|
+
assert normalize_path(value, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(tmp_path / value))]
|
|
228
|
+
|
|
229
|
+
@pytest.mark.parametrize("base", [None, ".", "nested"])
|
|
230
|
+
def test_rejects_empty_input_regardless_of_base(self, tmp_path: Path, base: str | None) -> None:
|
|
231
|
+
cwd = None if base is None else tmp_path / base
|
|
232
|
+
|
|
233
|
+
assert normalize_path("", tmp_path, cwd=cwd).unwrap_err() == [InvalidProjectPath(path="")]
|
|
234
|
+
|
|
235
|
+
def test_dot_identifies_explicit_non_root_base(self, tmp_path: Path) -> None:
|
|
236
|
+
assert normalize_path(".", tmp_path, cwd=tmp_path / "nested").unwrap() == "@/nested"
|
|
237
|
+
|
|
238
|
+
def test_relative_root_uses_absolute_candidate_in_diagnostic(
|
|
239
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
240
|
+
) -> None:
|
|
241
|
+
monkeypatch.chdir(tmp_path)
|
|
242
|
+
|
|
243
|
+
assert normalize_path("../outside", Path("project")).unwrap_err() == [
|
|
244
|
+
InvalidProjectPath(path=str(tmp_path / "project" / ".." / "outside"))
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def test_expands_home_marker(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
248
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
249
|
+
|
|
250
|
+
assert normalize_path("~/project/file", tmp_path / "project").unwrap() == "@/file"
|
|
251
|
+
|
|
252
|
+
def test_expanded_home_must_be_inside_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
253
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
254
|
+
|
|
255
|
+
assert normalize_path("~/file", tmp_path / "project").unwrap_err() == [
|
|
256
|
+
InvalidProjectPath(path=str(tmp_path / "file"))
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
def test_accepts_literal_home_marker_segment(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
260
|
+
monkeypatch.setenv("HOME", str(tmp_path.parent))
|
|
261
|
+
|
|
262
|
+
assert normalize_path("@/~/file", tmp_path).unwrap() == "@/~/file"
|
|
263
|
+
|
|
264
|
+
@pytest.mark.parametrize("value", [" notes /Заметки проекта.md ", " "])
|
|
265
|
+
def test_preserves_segment_text(self, tmp_path: Path, value: str) -> None:
|
|
266
|
+
assert normalize_path(value, tmp_path).unwrap() == "@/" + value
|
|
267
|
+
|
|
268
|
+
@pytest.mark.parametrize("value", ["", "@/file", "@/../file", "file", "~/file"])
|
|
269
|
+
def test_root_resolution_failure_precedes_input_processing(self, tmp_path: Path, value: str) -> None:
|
|
270
|
+
root = tmp_path / "loop"
|
|
271
|
+
root.symlink_to(root)
|
|
272
|
+
|
|
273
|
+
failure = normalize_path(value, root).unwrap_err()[0]
|
|
274
|
+
|
|
275
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
276
|
+
assert failure.path == str(root)
|
|
277
|
+
assert isinstance(failure.cause, (OSError, RuntimeError))
|
|
278
|
+
|
|
279
|
+
def test_rejects_target_symlink_loop(self, tmp_path: Path) -> None:
|
|
280
|
+
link = tmp_path / "loop"
|
|
281
|
+
link.symlink_to(link)
|
|
282
|
+
|
|
283
|
+
failure = normalize_path("loop/file", tmp_path).unwrap_err()[0]
|
|
284
|
+
|
|
285
|
+
assert failure == InvalidProjectPath(path=str(link / "file"))
|
|
286
|
+
|
|
287
|
+
@pytest.mark.parametrize("cause", [RuntimeError("unknown home"), OSError("home lookup failed")])
|
|
288
|
+
def test_home_expansion_failure_preserves_diagnostic(
|
|
289
|
+
self, tmp_path: Path, mocker: MockerFixture, cause: Exception
|
|
290
|
+
) -> None:
|
|
291
|
+
mocker.patch.object(Path, "expanduser", side_effect=cause)
|
|
292
|
+
|
|
293
|
+
failures = normalize_path("~/file", tmp_path).unwrap_err()
|
|
294
|
+
|
|
295
|
+
assert len(failures) == 1
|
|
296
|
+
failure = failures[0]
|
|
297
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
298
|
+
assert failure.code == "path_resolution_failed"
|
|
299
|
+
assert failure.path == "~/file"
|
|
300
|
+
assert failure.reason == str(cause)
|
|
301
|
+
assert failure.cause == cause
|
|
302
|
+
assert "cause" not in failure.as_record()
|
|
303
|
+
|
|
304
|
+
def test_unexpected_exception_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
305
|
+
mocker.patch.object(Path, "expanduser", side_effect=ValueError("unexpected failure"))
|
|
306
|
+
|
|
307
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
308
|
+
normalize_path("file", tmp_path)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class TestProjectPathIdFromFilesystem:
|
|
312
|
+
@pytest.mark.parametrize("kind", ["file", "directory", "missing"])
|
|
313
|
+
def test_converts_filesystem_path(self, tmp_path: Path, kind: str) -> None:
|
|
314
|
+
path = tmp_path / "target"
|
|
315
|
+
if kind == "file":
|
|
316
|
+
path.touch()
|
|
317
|
+
elif kind == "directory":
|
|
318
|
+
path.mkdir()
|
|
319
|
+
|
|
320
|
+
assert project_path_id_from_filesystem(path, tmp_path).unwrap() == "@/target"
|
|
321
|
+
|
|
322
|
+
def test_preserves_segment_text(self, tmp_path: Path) -> None:
|
|
323
|
+
path = tmp_path / " notes " / "Заметки проекта.md "
|
|
324
|
+
|
|
325
|
+
assert project_path_id_from_filesystem(path, tmp_path).unwrap() == "@/ notes /Заметки проекта.md "
|
|
326
|
+
|
|
327
|
+
def test_relative_root_and_path_use_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
328
|
+
monkeypatch.chdir(tmp_path)
|
|
329
|
+
|
|
330
|
+
assert project_path_id_from_filesystem(Path("project/file"), Path("project")).unwrap() == "@/file"
|
|
331
|
+
|
|
332
|
+
def test_relative_path_uses_cwd_instead_of_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
333
|
+
cwd = tmp_path / "nested"
|
|
334
|
+
cwd.mkdir()
|
|
335
|
+
monkeypatch.chdir(cwd)
|
|
336
|
+
|
|
337
|
+
assert project_path_id_from_filesystem(Path("file"), tmp_path).unwrap() == "@/nested/file"
|
|
338
|
+
|
|
339
|
+
@pytest.mark.parametrize("name", ["~", "@"])
|
|
340
|
+
def test_treats_markers_as_filesystem_segments(
|
|
341
|
+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str
|
|
342
|
+
) -> None:
|
|
343
|
+
monkeypatch.chdir(tmp_path)
|
|
344
|
+
|
|
345
|
+
assert project_path_id_from_filesystem(Path(name) / "file", tmp_path).unwrap() == f"@/{name}/file"
|
|
346
|
+
|
|
347
|
+
def test_resolves_root_symlink(self, tmp_path: Path) -> None:
|
|
348
|
+
root = tmp_path / "project"
|
|
349
|
+
root.mkdir()
|
|
350
|
+
link = tmp_path / "link"
|
|
351
|
+
link.symlink_to(root, target_is_directory=True)
|
|
352
|
+
|
|
353
|
+
assert project_path_id_from_filesystem(link / "file", link).unwrap() == "@/file"
|
|
354
|
+
|
|
355
|
+
def test_rejects_internal_symlink(self, tmp_path: Path) -> None:
|
|
356
|
+
target = tmp_path / "target"
|
|
357
|
+
target.mkdir()
|
|
358
|
+
link = tmp_path / "link"
|
|
359
|
+
link.symlink_to(target, target_is_directory=True)
|
|
360
|
+
|
|
361
|
+
assert project_path_id_from_filesystem(link / "file", tmp_path).unwrap_err() == [
|
|
362
|
+
InvalidProjectPath(path=str(link / "file"))
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
def test_resolves_parent_segments(self, tmp_path: Path) -> None:
|
|
366
|
+
path = tmp_path / "nested" / ".." / "file"
|
|
367
|
+
|
|
368
|
+
assert project_path_id_from_filesystem(path, tmp_path).unwrap() == "@/file"
|
|
369
|
+
|
|
370
|
+
@pytest.mark.parametrize("relative", [".", "..", "../outside"])
|
|
371
|
+
def test_rejects_root_and_outside_paths(self, tmp_path: Path, relative: str) -> None:
|
|
372
|
+
path = tmp_path / relative
|
|
373
|
+
|
|
374
|
+
assert project_path_id_from_filesystem(path, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(path))]
|
|
375
|
+
|
|
376
|
+
def test_rejects_symlink_outside_root(self, tmp_path: Path) -> None:
|
|
377
|
+
link = tmp_path / "link"
|
|
378
|
+
link.symlink_to(tmp_path.parent, target_is_directory=True)
|
|
379
|
+
path = link / "file"
|
|
380
|
+
|
|
381
|
+
assert project_path_id_from_filesystem(path, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(path))]
|
|
382
|
+
|
|
383
|
+
def test_preserves_root_resolution_failure(self, tmp_path: Path) -> None:
|
|
384
|
+
link = tmp_path / "loop"
|
|
385
|
+
link.symlink_to(link)
|
|
386
|
+
root = link
|
|
387
|
+
path = tmp_path / "file"
|
|
388
|
+
|
|
389
|
+
failures = project_path_id_from_filesystem(path, root).unwrap_err()
|
|
390
|
+
|
|
391
|
+
assert len(failures) == 1
|
|
392
|
+
failure = failures[0]
|
|
393
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
394
|
+
assert failure.code == "path_resolution_failed"
|
|
395
|
+
assert failure.path == str(link)
|
|
396
|
+
assert isinstance(failure.cause, (OSError, RuntimeError))
|
|
397
|
+
assert failure.reason == str(failure.cause)
|
|
398
|
+
|
|
399
|
+
def test_unexpected_exception_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
400
|
+
mocker.patch.object(Path, "resolve", side_effect=ValueError("unexpected failure"))
|
|
401
|
+
|
|
402
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
403
|
+
project_path_id_from_filesystem(tmp_path / "file", tmp_path)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class TestProjectPathIdFromResolved:
|
|
407
|
+
@pytest.mark.parametrize("kind", ["file", "directory", "missing"])
|
|
408
|
+
def test_converts_resolved_path(self, tmp_path: Path, kind: str) -> None:
|
|
409
|
+
path = tmp_path / "target"
|
|
410
|
+
if kind == "file":
|
|
411
|
+
path.touch()
|
|
412
|
+
elif kind == "directory":
|
|
413
|
+
path.mkdir()
|
|
414
|
+
root = resolve_project_root(tmp_path).unwrap()
|
|
415
|
+
resolved = resolve_inside_project(path, root).unwrap()
|
|
416
|
+
|
|
417
|
+
assert project_path_id_from_resolved(resolved, root) == "@/target"
|
|
418
|
+
|
|
419
|
+
@pytest.mark.parametrize(
|
|
420
|
+
"relative",
|
|
421
|
+
[
|
|
422
|
+
"nested/file.txt",
|
|
423
|
+
"README",
|
|
424
|
+
"Case/FILE",
|
|
425
|
+
" notes /Заметки проекта.md ",
|
|
426
|
+
r"a\b/file",
|
|
427
|
+
"name:section",
|
|
428
|
+
"~/file",
|
|
429
|
+
],
|
|
430
|
+
)
|
|
431
|
+
def test_preserves_segment_text(self, tmp_path: Path, relative: str) -> None:
|
|
432
|
+
root = resolve_project_root(tmp_path).unwrap()
|
|
433
|
+
resolved = resolve_inside_project(tmp_path / relative, root).unwrap()
|
|
434
|
+
|
|
435
|
+
assert project_path_id_from_resolved(resolved, root) == "@/" + relative
|
|
436
|
+
|
|
437
|
+
def test_uses_root_independently_of_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
438
|
+
root = resolve_project_root(tmp_path / "project").unwrap()
|
|
439
|
+
resolved = resolve_inside_project(root / "file.txt", root).unwrap()
|
|
440
|
+
monkeypatch.chdir(tmp_path)
|
|
441
|
+
|
|
442
|
+
assert project_path_id_from_resolved(resolved, root) == "@/file.txt"
|
|
443
|
+
|
|
444
|
+
def test_does_not_access_filesystem(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
445
|
+
root = resolve_project_root(tmp_path).unwrap()
|
|
446
|
+
resolved = resolve_inside_project(root / "file.txt", root).unwrap()
|
|
447
|
+
mocker.patch.object(Path, "resolve", side_effect=AssertionError("unexpected path resolution"))
|
|
448
|
+
mocker.patch.object(Path, "stat", side_effect=AssertionError("unexpected filesystem access"))
|
|
449
|
+
|
|
450
|
+
assert project_path_id_from_resolved(resolved, root) == "@/file.txt"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class TestResolveProjectRoot:
|
|
454
|
+
def test_normalizes_parent_segments(self, tmp_path: Path) -> None:
|
|
455
|
+
project = tmp_path / "project"
|
|
456
|
+
project.mkdir()
|
|
457
|
+
|
|
458
|
+
assert resolve_project_root(project / ".." / "project" / ".").unwrap() == project
|
|
459
|
+
|
|
460
|
+
def test_resolves_relative_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
461
|
+
monkeypatch.chdir(tmp_path)
|
|
462
|
+
|
|
463
|
+
assert resolve_project_root(Path("project")).unwrap() == tmp_path / "project"
|
|
464
|
+
|
|
465
|
+
def test_resolves_current_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
466
|
+
monkeypatch.chdir(tmp_path)
|
|
467
|
+
|
|
468
|
+
assert resolve_project_root(Path()).unwrap() == tmp_path
|
|
469
|
+
|
|
470
|
+
def test_accepts_missing_directory(self, tmp_path: Path) -> None:
|
|
471
|
+
root = tmp_path / "missing" / "project"
|
|
472
|
+
|
|
473
|
+
assert resolve_project_root(root).unwrap() == root
|
|
474
|
+
assert not root.exists()
|
|
475
|
+
|
|
476
|
+
def test_does_not_validate_directory_kind(self, tmp_path: Path) -> None:
|
|
477
|
+
root = tmp_path / "file"
|
|
478
|
+
root.write_text("", encoding="utf-8")
|
|
479
|
+
|
|
480
|
+
assert resolve_project_root(root).unwrap() == root
|
|
481
|
+
|
|
482
|
+
def test_does_not_expand_home_marker(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
483
|
+
monkeypatch.chdir(tmp_path)
|
|
484
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
485
|
+
|
|
486
|
+
assert resolve_project_root(Path("~/project")).unwrap() == tmp_path / "~" / "project"
|
|
487
|
+
|
|
488
|
+
def test_follows_symlinks(self, tmp_path: Path) -> None:
|
|
489
|
+
target = tmp_path / "target"
|
|
490
|
+
target.mkdir()
|
|
491
|
+
root = tmp_path / "link"
|
|
492
|
+
root.symlink_to(target, target_is_directory=True)
|
|
493
|
+
|
|
494
|
+
assert resolve_project_root(root).unwrap() == target
|
|
495
|
+
|
|
496
|
+
def test_symlink_loop_returns_resolution_failure(self, tmp_path: Path) -> None:
|
|
497
|
+
root = tmp_path / "loop"
|
|
498
|
+
root.symlink_to(root)
|
|
499
|
+
|
|
500
|
+
failure = resolve_project_root(root).unwrap_err()[0]
|
|
501
|
+
|
|
502
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
503
|
+
assert failure.code == "path_resolution_failed"
|
|
504
|
+
assert failure.path == str(root)
|
|
505
|
+
assert isinstance(failure.cause, (OSError, RuntimeError))
|
|
506
|
+
assert failure.reason == str(failure.cause)
|
|
507
|
+
|
|
508
|
+
@pytest.mark.parametrize("original", [PermissionError("permission denied"), RuntimeError("symlink loop")])
|
|
509
|
+
def test_resolution_failure_preserves_cause(
|
|
510
|
+
self, tmp_path: Path, mocker: MockerFixture, original: Exception
|
|
511
|
+
) -> None:
|
|
512
|
+
mocker.patch.object(Path, "resolve", side_effect=original)
|
|
513
|
+
|
|
514
|
+
failures = resolve_project_root(tmp_path).unwrap_err()
|
|
515
|
+
|
|
516
|
+
assert len(failures) == 1
|
|
517
|
+
failure = failures[0]
|
|
518
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
519
|
+
assert failure.code == "path_resolution_failed"
|
|
520
|
+
assert failure.path == str(tmp_path)
|
|
521
|
+
assert failure.reason == str(original)
|
|
522
|
+
assert failure.cause == original
|
|
523
|
+
|
|
524
|
+
def test_unexpected_exception_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
525
|
+
mocker.patch.object(Path, "resolve", side_effect=ValueError("unexpected failure"))
|
|
526
|
+
|
|
527
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
528
|
+
resolve_project_root(tmp_path)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class TestValidateNoSymlinks:
|
|
532
|
+
@pytest.mark.parametrize("target_kind", ["file", "directory", "missing", "loop", "outside"])
|
|
533
|
+
@pytest.mark.parametrize("suffix", ["", "/child", "/../other"])
|
|
534
|
+
def test_rejects_links_before_following_them(self, tmp_path: Path, target_kind: str, suffix: str) -> None:
|
|
535
|
+
target = tmp_path / "target"
|
|
536
|
+
link = tmp_path / "link"
|
|
537
|
+
if target_kind == "file":
|
|
538
|
+
target.touch()
|
|
539
|
+
elif target_kind == "directory":
|
|
540
|
+
target.mkdir()
|
|
541
|
+
elif target_kind == "loop":
|
|
542
|
+
target = link
|
|
543
|
+
elif target_kind == "outside":
|
|
544
|
+
target = tmp_path.parent
|
|
545
|
+
link.symlink_to(target)
|
|
546
|
+
candidate = Path(str(link) + suffix)
|
|
547
|
+
|
|
548
|
+
assert _validate_no_symlinks(candidate, ProjectRootPath(tmp_path)).unwrap_err() == [
|
|
549
|
+
InvalidProjectPath(path=str(candidate))
|
|
550
|
+
]
|
|
551
|
+
|
|
552
|
+
def test_root_alias_does_not_hide_descendant_link(self, tmp_path: Path) -> None:
|
|
553
|
+
root = tmp_path / "project"
|
|
554
|
+
root.mkdir()
|
|
555
|
+
alias = tmp_path / "alias"
|
|
556
|
+
alias.symlink_to(root)
|
|
557
|
+
(root / "link").symlink_to(root / "missing")
|
|
558
|
+
|
|
559
|
+
assert _validate_no_symlinks(alias / "ordinary", ProjectRootPath(root)).is_ok()
|
|
560
|
+
assert _validate_no_symlinks(alias / "link/file", ProjectRootPath(root)).unwrap_err() == [
|
|
561
|
+
InvalidProjectPath(path=str(alias / "link/file"))
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
def test_missing_paths_and_parent_segments_remain_valid(self, tmp_path: Path) -> None:
|
|
565
|
+
assert _validate_no_symlinks(tmp_path / "missing/../file", ProjectRootPath(tmp_path)).is_ok()
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class TestResolveInsideProject:
|
|
569
|
+
def test_symlink_inspection_failure_preserves_cause(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
570
|
+
cause = PermissionError("cannot inspect path")
|
|
571
|
+
mocker.patch.object(Path, "lstat", side_effect=cause)
|
|
572
|
+
|
|
573
|
+
failure = resolve_inside_project(tmp_path / "file", ProjectRootPath(tmp_path)).unwrap_err()[0]
|
|
574
|
+
|
|
575
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
576
|
+
assert failure.path == str(tmp_path / "file")
|
|
577
|
+
assert failure.cause == cause
|
|
578
|
+
|
|
579
|
+
def test_resolves_file_and_parent_segments(self, tmp_path: Path) -> None:
|
|
580
|
+
target = tmp_path / "file"
|
|
581
|
+
target.touch()
|
|
582
|
+
path = tmp_path / "nested" / ".." / "file"
|
|
583
|
+
|
|
584
|
+
assert resolve_inside_project(path, ProjectRootPath(tmp_path)).unwrap() == target
|
|
585
|
+
|
|
586
|
+
def test_accepts_directory(self, tmp_path: Path) -> None:
|
|
587
|
+
path = tmp_path / "directory"
|
|
588
|
+
path.mkdir()
|
|
589
|
+
|
|
590
|
+
assert resolve_inside_project(path, ProjectRootPath(tmp_path)).unwrap() == path
|
|
591
|
+
|
|
592
|
+
def test_accepts_missing_target(self, tmp_path: Path) -> None:
|
|
593
|
+
path = tmp_path / "missing" / "file"
|
|
594
|
+
|
|
595
|
+
assert resolve_inside_project(path, ProjectRootPath(tmp_path)).unwrap() == path
|
|
596
|
+
assert not path.exists()
|
|
597
|
+
|
|
598
|
+
def test_resolves_relative_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
599
|
+
cwd = tmp_path / "nested"
|
|
600
|
+
cwd.mkdir()
|
|
601
|
+
monkeypatch.chdir(cwd)
|
|
602
|
+
|
|
603
|
+
assert resolve_inside_project(Path("file"), ProjectRootPath(tmp_path)).unwrap() == (cwd / "file")
|
|
604
|
+
|
|
605
|
+
def test_does_not_expand_home_marker(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
606
|
+
monkeypatch.chdir(tmp_path)
|
|
607
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
608
|
+
|
|
609
|
+
assert resolve_inside_project(Path("~/file"), ProjectRootPath(tmp_path)).unwrap() == (tmp_path / "~" / "file")
|
|
610
|
+
|
|
611
|
+
@pytest.mark.parametrize("relative", [".", "..", "../outside", "../project-sibling/file"])
|
|
612
|
+
def test_rejects_root_and_outside_paths(self, tmp_path: Path, relative: str) -> None:
|
|
613
|
+
root = ProjectRootPath(tmp_path / "project")
|
|
614
|
+
|
|
615
|
+
errors = resolve_inside_project(root / relative, root).unwrap_err()
|
|
616
|
+
|
|
617
|
+
assert len(errors) == 1
|
|
618
|
+
error = errors[0]
|
|
619
|
+
assert isinstance(error, InvalidProjectPath)
|
|
620
|
+
assert error.code == "invalid_project_path"
|
|
621
|
+
assert error.path == str(root / relative)
|
|
622
|
+
|
|
623
|
+
def test_rejects_symlink_inside_root(self, tmp_path: Path) -> None:
|
|
624
|
+
target = tmp_path / "target"
|
|
625
|
+
target.mkdir()
|
|
626
|
+
link = tmp_path / "link"
|
|
627
|
+
link.symlink_to(target, target_is_directory=True)
|
|
628
|
+
|
|
629
|
+
assert resolve_inside_project(link / "file", ProjectRootPath(tmp_path)).unwrap_err() == [
|
|
630
|
+
InvalidProjectPath(path=str(link / "file"))
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
@pytest.mark.parametrize("target", [".", ".."])
|
|
634
|
+
def test_rejects_symlink_to_root_or_outside(self, tmp_path: Path, target: str) -> None:
|
|
635
|
+
link = tmp_path / "link"
|
|
636
|
+
link.symlink_to(tmp_path / target, target_is_directory=True)
|
|
637
|
+
|
|
638
|
+
assert resolve_inside_project(link, ProjectRootPath(tmp_path)).unwrap_err() == [
|
|
639
|
+
InvalidProjectPath(path=str(link))
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
def test_rejects_symlink_loop(self, tmp_path: Path) -> None:
|
|
643
|
+
link = tmp_path / "loop"
|
|
644
|
+
link.symlink_to(link)
|
|
645
|
+
|
|
646
|
+
failure = resolve_inside_project(link, ProjectRootPath(tmp_path)).unwrap_err()[0]
|
|
647
|
+
|
|
648
|
+
assert failure == InvalidProjectPath(path=str(link))
|
|
649
|
+
|
|
650
|
+
@pytest.mark.parametrize("cause", [PermissionError(" permission denied "), RuntimeError(" symlink loop ")])
|
|
651
|
+
def test_resolution_failure_preserves_diagnostic_and_private_cause(
|
|
652
|
+
self, tmp_path: Path, mocker: MockerFixture, cause: Exception
|
|
653
|
+
) -> None:
|
|
654
|
+
mocker.patch.object(Path, "resolve", side_effect=cause)
|
|
655
|
+
|
|
656
|
+
errors = resolve_inside_project(tmp_path / "file", ProjectRootPath(tmp_path)).unwrap_err()
|
|
657
|
+
|
|
658
|
+
assert len(errors) == 1
|
|
659
|
+
failure = errors[0]
|
|
660
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
661
|
+
assert failure.cause == cause
|
|
662
|
+
assert failure.as_record() == {
|
|
663
|
+
"type": "error",
|
|
664
|
+
"code": "path_resolution_failed",
|
|
665
|
+
"message": failure.format_message(),
|
|
666
|
+
"path": str(tmp_path / "file"),
|
|
667
|
+
"reason": str(cause).strip(),
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
def test_unexpected_exception_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
671
|
+
mocker.patch.object(Path, "resolve", side_effect=ValueError("unexpected failure"))
|
|
672
|
+
|
|
673
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
674
|
+
resolve_inside_project(tmp_path / "file", ProjectRootPath(tmp_path))
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
class TestResolveRootAnchoredPath:
|
|
678
|
+
def test_resolves_existing_file(self, tmp_path: Path) -> None:
|
|
679
|
+
target = tmp_path / "file"
|
|
680
|
+
target.touch()
|
|
681
|
+
|
|
682
|
+
assert resolve_root_anchored_path("@/file", ProjectRootPath(tmp_path)).unwrap() == target
|
|
683
|
+
|
|
684
|
+
def test_accepts_directory(self, tmp_path: Path) -> None:
|
|
685
|
+
target = tmp_path / "directory"
|
|
686
|
+
target.mkdir()
|
|
687
|
+
|
|
688
|
+
assert resolve_root_anchored_path("@/directory", ProjectRootPath(tmp_path)).unwrap() == target
|
|
689
|
+
|
|
690
|
+
@pytest.mark.parametrize("relative", ["missing/file", "LICENSE", "notes/Заметки проекта.md", "~/file"])
|
|
691
|
+
def test_accepts_missing_target_and_preserves_segment_text(self, tmp_path: Path, relative: str) -> None:
|
|
692
|
+
target = tmp_path / relative
|
|
693
|
+
|
|
694
|
+
assert resolve_root_anchored_path("@/" + relative, ProjectRootPath(tmp_path)).unwrap() == target
|
|
695
|
+
assert not target.exists()
|
|
696
|
+
|
|
697
|
+
def test_uses_root_independently_of_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
698
|
+
root = tmp_path / "project"
|
|
699
|
+
cwd = tmp_path / "elsewhere"
|
|
700
|
+
cwd.mkdir()
|
|
701
|
+
monkeypatch.chdir(cwd)
|
|
702
|
+
|
|
703
|
+
assert resolve_root_anchored_path("@/file", ProjectRootPath(root)).unwrap() == root / "file"
|
|
704
|
+
|
|
705
|
+
def test_normalizes_before_resolving_symlinks(self, tmp_path: Path) -> None:
|
|
706
|
+
(tmp_path / "outside").symlink_to(tmp_path.parent, target_is_directory=True)
|
|
707
|
+
|
|
708
|
+
assert (
|
|
709
|
+
resolve_root_anchored_path("@/./outside/../file", ProjectRootPath(tmp_path)).unwrap() == tmp_path / "file"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
@pytest.mark.parametrize("value", ["", "file", "@file", "@/", "@/a/..", "@/a//b", "@/a/", "@/../a"])
|
|
713
|
+
def test_invalid_identifier_preserves_lexical_diagnostic(self, tmp_path: Path, value: str) -> None:
|
|
714
|
+
failures = resolve_root_anchored_path(value, ProjectRootPath(tmp_path)).unwrap_err()
|
|
715
|
+
|
|
716
|
+
assert len(failures) == 1
|
|
717
|
+
failure = failures[0]
|
|
718
|
+
assert isinstance(failure, InvalidProjectPath)
|
|
719
|
+
assert failure.code == "invalid_project_path"
|
|
720
|
+
assert failure.path == value
|
|
721
|
+
|
|
722
|
+
def test_rejects_symlink_inside_root(self, tmp_path: Path) -> None:
|
|
723
|
+
target = tmp_path / "target"
|
|
724
|
+
target.mkdir()
|
|
725
|
+
(tmp_path / "link").symlink_to(target, target_is_directory=True)
|
|
726
|
+
|
|
727
|
+
assert resolve_root_anchored_path("@/link/file", ProjectRootPath(tmp_path)).unwrap_err() == [
|
|
728
|
+
InvalidProjectPath(path=str(tmp_path / "link/file"))
|
|
729
|
+
]
|
|
730
|
+
|
|
731
|
+
@pytest.mark.parametrize("target", [".", ".."])
|
|
732
|
+
def test_rejects_symlink_to_root_or_outside(self, tmp_path: Path, target: str) -> None:
|
|
733
|
+
link = tmp_path / "link"
|
|
734
|
+
link.symlink_to(tmp_path / target, target_is_directory=True)
|
|
735
|
+
|
|
736
|
+
failures = resolve_root_anchored_path("@/link", ProjectRootPath(tmp_path)).unwrap_err()
|
|
737
|
+
|
|
738
|
+
assert len(failures) == 1
|
|
739
|
+
failure = failures[0]
|
|
740
|
+
assert isinstance(failure, InvalidProjectPath)
|
|
741
|
+
assert failure.code == "invalid_project_path"
|
|
742
|
+
assert failure.path == str(link)
|
|
743
|
+
|
|
744
|
+
def test_rejects_symlink_loop(self, tmp_path: Path) -> None:
|
|
745
|
+
link = tmp_path / "loop"
|
|
746
|
+
link.symlink_to(link)
|
|
747
|
+
|
|
748
|
+
failures = resolve_root_anchored_path("@/loop/file", ProjectRootPath(tmp_path)).unwrap_err()
|
|
749
|
+
|
|
750
|
+
assert len(failures) == 1
|
|
751
|
+
failure = failures[0]
|
|
752
|
+
assert failure == InvalidProjectPath(path=str(link / "file"))
|
|
753
|
+
|
|
754
|
+
def test_permission_failure_preserves_diagnostic_and_private_cause(
|
|
755
|
+
self, tmp_path: Path, mocker: MockerFixture
|
|
756
|
+
) -> None:
|
|
757
|
+
cause = PermissionError(" permission denied ")
|
|
758
|
+
mocker.patch.object(Path, "resolve", side_effect=cause)
|
|
759
|
+
|
|
760
|
+
failures = resolve_root_anchored_path("@/file", ProjectRootPath(tmp_path)).unwrap_err()
|
|
761
|
+
|
|
762
|
+
assert len(failures) == 1
|
|
763
|
+
failure = failures[0]
|
|
764
|
+
assert isinstance(failure, PathResolutionFailed)
|
|
765
|
+
assert failure.cause == cause
|
|
766
|
+
assert failure.as_record() == {
|
|
767
|
+
"type": "error",
|
|
768
|
+
"code": "path_resolution_failed",
|
|
769
|
+
"message": failure.format_message(),
|
|
770
|
+
"path": str(tmp_path / "file"),
|
|
771
|
+
"reason": str(cause).strip(),
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
def test_unexpected_exception_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
|
|
775
|
+
mocker.patch.object(Path, "resolve", side_effect=ValueError("unexpected failure"))
|
|
776
|
+
|
|
777
|
+
with pytest.raises(ValueError, match="unexpected failure"):
|
|
778
|
+
resolve_root_anchored_path("@/file", ProjectRootPath(tmp_path))
|