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.
Files changed (111) hide show
  1. llm_tool_cli/__init__.py +0 -0
  2. llm_tool_cli/cli/__init__.py +1 -0
  3. llm_tool_cli/cli/application.py +31 -0
  4. llm_tool_cli/cli/commands/__init__.py +0 -0
  5. llm_tool_cli/cli/commands/initialization.py +36 -0
  6. llm_tool_cli/cli/commands/skills.py +25 -0
  7. llm_tool_cli/cli/commands/tests/__init__.py +0 -0
  8. llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
  9. llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
  10. llm_tool_cli/cli/commands/tests/test_version.py +98 -0
  11. llm_tool_cli/cli/commands/version.py +17 -0
  12. llm_tool_cli/cli/context.py +39 -0
  13. llm_tool_cli/cli/entities.py +3 -0
  14. llm_tool_cli/cli/errors.py +15 -0
  15. llm_tool_cli/cli/handling.py +41 -0
  16. llm_tool_cli/cli/options.py +48 -0
  17. llm_tool_cli/cli/tests/__init__.py +0 -0
  18. llm_tool_cli/cli/tests/test_application.py +167 -0
  19. llm_tool_cli/cli/tests/test_context.py +179 -0
  20. llm_tool_cli/cli/tests/test_handling.py +230 -0
  21. llm_tool_cli/cli/tests/test_options.py +125 -0
  22. llm_tool_cli/config/__init__.py +33 -0
  23. llm_tool_cli/config/errors.py +92 -0
  24. llm_tool_cli/config/fields.py +39 -0
  25. llm_tool_cli/config/files.py +175 -0
  26. llm_tool_cli/config/project.py +87 -0
  27. llm_tool_cli/config/runtime.py +14 -0
  28. llm_tool_cli/config/tests/__init__.py +0 -0
  29. llm_tool_cli/config/tests/fixtures.py +34 -0
  30. llm_tool_cli/config/tests/test_fields.py +124 -0
  31. llm_tool_cli/config/tests/test_files.py +826 -0
  32. llm_tool_cli/config/tests/test_project.py +257 -0
  33. llm_tool_cli/core/__init__.py +1 -0
  34. llm_tool_cli/core/context.py +50 -0
  35. llm_tool_cli/core/entities.py +35 -0
  36. llm_tool_cli/core/errors.py +97 -0
  37. llm_tool_cli/core/result.py +118 -0
  38. llm_tool_cli/core/settings.py +37 -0
  39. llm_tool_cli/core/tests/__init__.py +0 -0
  40. llm_tool_cli/core/tests/fixtures.py +13 -0
  41. llm_tool_cli/core/tests/test_context.py +152 -0
  42. llm_tool_cli/core/tests/test_entities.py +65 -0
  43. llm_tool_cli/core/tests/test_errors.py +211 -0
  44. llm_tool_cli/core/tests/test_result.py +256 -0
  45. llm_tool_cli/core/tests/test_settings.py +64 -0
  46. llm_tool_cli/output/__init__.py +3 -0
  47. llm_tool_cli/output/emitter.py +108 -0
  48. llm_tool_cli/output/errors.py +21 -0
  49. llm_tool_cli/output/journal.py +66 -0
  50. llm_tool_cli/output/tests/__init__.py +0 -0
  51. llm_tool_cli/output/tests/helpers.py +29 -0
  52. llm_tool_cli/output/tests/test_emitter.py +406 -0
  53. llm_tool_cli/output/tests/test_journal.py +141 -0
  54. llm_tool_cli/paths/__init__.py +51 -0
  55. llm_tool_cli/paths/enumeration.py +72 -0
  56. llm_tool_cli/paths/errors.py +42 -0
  57. llm_tool_cli/paths/filesystem.py +164 -0
  58. llm_tool_cli/paths/globs.py +124 -0
  59. llm_tool_cli/paths/normalization.py +58 -0
  60. llm_tool_cli/paths/tests/__init__.py +0 -0
  61. llm_tool_cli/paths/tests/test_enumeration.py +229 -0
  62. llm_tool_cli/paths/tests/test_filesystem.py +778 -0
  63. llm_tool_cli/paths/tests/test_globs.py +137 -0
  64. llm_tool_cli/paths/tests/test_normalization.py +163 -0
  65. llm_tool_cli/protocol/__init__.py +4 -0
  66. llm_tool_cli/protocol/cell_shortcuts.py +39 -0
  67. llm_tool_cli/protocol/entities.py +7 -0
  68. llm_tool_cli/protocol/errors.py +21 -0
  69. llm_tool_cli/protocol/journal.py +21 -0
  70. llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
  71. llm_tool_cli/protocol/logic_cells/base.py +41 -0
  72. llm_tool_cli/protocol/logic_cells/content.py +26 -0
  73. llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
  74. llm_tool_cli/protocol/logic_cells/journal.py +44 -0
  75. llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
  76. llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
  77. llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
  78. llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
  79. llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
  80. llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
  81. llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
  82. llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
  83. llm_tool_cli/protocol/logic_cells/warning.py +22 -0
  84. llm_tool_cli/protocol/output_cells/__init__.py +11 -0
  85. llm_tool_cli/protocol/output_cells/automation.py +14 -0
  86. llm_tool_cli/protocol/output_cells/base.py +65 -0
  87. llm_tool_cli/protocol/output_cells/human.py +24 -0
  88. llm_tool_cli/protocol/output_cells/journal.py +23 -0
  89. llm_tool_cli/protocol/output_cells/llm.py +25 -0
  90. llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
  91. llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
  92. llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
  93. llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
  94. llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
  95. llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
  96. llm_tool_cli/protocol/serialization.py +7 -0
  97. llm_tool_cli/protocol/tests/__init__.py +0 -0
  98. llm_tool_cli/protocol/tests/helpers.py +25 -0
  99. llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
  100. llm_tool_cli/protocol/tests/test_journal.py +44 -0
  101. llm_tool_cli/protocol/tests/test_serialization.py +33 -0
  102. llm_tool_cli/py.typed +0 -0
  103. llm_tool_cli/skills/__init__.py +6 -0
  104. llm_tool_cli/skills/errors.py +12 -0
  105. llm_tool_cli/skills/fixtures.py +14 -0
  106. llm_tool_cli/skills/tests/__init__.py +0 -0
  107. llm_tool_cli/skills/tests/test_fixtures.py +58 -0
  108. llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
  109. llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
  110. llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
  111. 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))