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,826 @@
1
+ import contextlib
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pydantic
7
+ import pytest
8
+ from pytest_mock import MockerFixture
9
+
10
+ from llm_tool_cli.config import (
11
+ create_config,
12
+ create_config_from_template,
13
+ errors,
14
+ find_config,
15
+ initialize_config,
16
+ load_config,
17
+ locate_config,
18
+ read_toml,
19
+ resolve_config_path,
20
+ resolve_init_config_path,
21
+ )
22
+ from llm_tool_cli.core.errors import EnvironmentErrors
23
+ from llm_tool_cli.core.result import Err, Result
24
+ from llm_tool_cli.paths import PathInput, ProjectConfigPath
25
+ from llm_tool_cli.paths.errors import InvalidProjectPath
26
+
27
+
28
+ @pytest.fixture
29
+ def config_package(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
30
+ package = "config_fixture_package"
31
+ initializer = tmp_path / "__init__.py"
32
+ initializer.write_text("", encoding="utf-8")
33
+ spec = importlib.util.spec_from_file_location(package, initializer)
34
+ assert spec is not None
35
+ monkeypatch.setitem(sys.modules, package, importlib.util.module_from_spec(spec))
36
+ (tmp_path / "fixtures").mkdir()
37
+ return package
38
+
39
+
40
+ class TestFindConfig:
41
+ def test_nearest_file(self, tmp_path: Path) -> None:
42
+ nested = tmp_path / "nested"
43
+ nested.mkdir()
44
+ (tmp_path / "config.toml").touch()
45
+ nearest = nested / "config.toml"
46
+ nearest.write_text("not valid TOML", encoding="utf-8")
47
+
48
+ assert find_config("config.toml", nested).unwrap() == nearest
49
+
50
+ def test_searches_parents_and_skips_directories(self, tmp_path: Path) -> None:
51
+ nested = tmp_path / "nested"
52
+ nested.mkdir()
53
+ (nested / "config.toml").mkdir()
54
+ outer = tmp_path / "config.toml"
55
+ outer.touch()
56
+
57
+ assert find_config("config.toml", nested).unwrap() == outer
58
+
59
+ def test_missing(self, tmp_path: Path) -> None:
60
+ assert find_config(f"{tmp_path.name}-missing.toml", tmp_path).unwrap() is None
61
+
62
+ def test_searches_filesystem_root(self, tmp_path: Path, mocker: MockerFixture) -> None:
63
+ root_config = Path(tmp_path.anchor) / "config.toml"
64
+ mocker.patch.object(Path, "is_file", autospec=True, side_effect=lambda path: path == root_config)
65
+
66
+ assert find_config("config.toml", tmp_path).unwrap() == root_config
67
+
68
+ def test_resolves_relative_start(self, tmp_path: Path) -> None:
69
+ config_path = tmp_path / "config.toml"
70
+ config_path.touch()
71
+
72
+ with contextlib.chdir(tmp_path):
73
+ assert find_config("config.toml", Path(".")).unwrap() == config_path
74
+
75
+ @pytest.mark.parametrize("target_kind", ["file", "missing", "loop", "directory"])
76
+ def test_rejects_symlink_without_falling_back_to_parent(self, tmp_path: Path, target_kind: str) -> None:
77
+ nested = tmp_path / "nested"
78
+ nested.mkdir()
79
+ (tmp_path / "config.toml").touch()
80
+ config_path = nested / "config.toml"
81
+ target = config_path if target_kind == "loop" else nested / "target"
82
+ if target_kind == "file":
83
+ target.touch()
84
+ elif target_kind == "directory":
85
+ target.mkdir()
86
+ config_path.symlink_to(target)
87
+
88
+ assert find_config("config.toml", nested).unwrap_err() == [InvalidProjectPath(path=str(config_path))]
89
+
90
+ def test_accepts_start_directory_alias(self, tmp_path: Path) -> None:
91
+ root = tmp_path / "project"
92
+ root.mkdir()
93
+ config_path = root / "config.toml"
94
+ config_path.touch()
95
+ alias = tmp_path / "alias"
96
+ alias.symlink_to(root, target_is_directory=True)
97
+
98
+ assert find_config("config.toml", alias).unwrap() == config_path
99
+
100
+ def test_resolution_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
101
+ mocker.patch.object(Path, "resolve", side_effect=OSError("resolution failed"))
102
+
103
+ caught = find_config("config.toml", tmp_path).unwrap_err()[0]
104
+ assert isinstance(caught, errors.DiscoveryFailed)
105
+
106
+ assert caught.code == "config_discovery_failed"
107
+ assert caught.path == tmp_path
108
+ assert isinstance(caught.cause, OSError)
109
+
110
+ def test_inspection_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
111
+ mocker.patch.object(Path, "is_file", side_effect=PermissionError("access denied"))
112
+
113
+ caught = find_config("config.toml", tmp_path).unwrap_err()[0]
114
+ assert isinstance(caught, errors.DiscoveryFailed)
115
+
116
+ assert caught.code == "config_discovery_failed"
117
+ assert caught.path == tmp_path / "config.toml"
118
+ assert isinstance(caught.cause, PermissionError)
119
+ assert caught.reason == str(caught.cause)
120
+
121
+
122
+ class TestResolveConfigPath:
123
+ def test_relative_missing_path(self, tmp_path: Path) -> None:
124
+ assert resolve_config_path(Path("nested/../custom.toml"), tmp_path).unwrap() == tmp_path / "custom.toml"
125
+
126
+ def test_absolute_path_ignores_cwd(self, tmp_path: Path) -> None:
127
+ config_path = tmp_path / "custom.toml"
128
+
129
+ assert resolve_config_path(config_path, tmp_path / "missing").unwrap() == config_path
130
+
131
+ def test_relative_cwd(self, tmp_path: Path) -> None:
132
+ with contextlib.chdir(tmp_path):
133
+ assert resolve_config_path(Path("custom.toml"), Path("nested")).unwrap() == tmp_path / "nested/custom.toml"
134
+
135
+ @pytest.mark.parametrize("target_kind", ["file", "missing", "loop"])
136
+ def test_rejects_symlinks(self, tmp_path: Path, target_kind: str) -> None:
137
+ link = tmp_path / "custom.toml"
138
+ target = link if target_kind == "loop" else tmp_path / "target.toml"
139
+ if target_kind == "file":
140
+ target.touch()
141
+ link.symlink_to(target)
142
+
143
+ assert resolve_config_path(link, tmp_path).unwrap_err() == [InvalidProjectPath(path=str(link))]
144
+
145
+ def test_accepts_containing_directory_alias(self, tmp_path: Path) -> None:
146
+ root = tmp_path / "project"
147
+ root.mkdir()
148
+ alias = tmp_path / "alias"
149
+ alias.symlink_to(root, target_is_directory=True)
150
+
151
+ assert resolve_config_path(alias / "config.toml", tmp_path).unwrap() == root / "config.toml"
152
+
153
+ def test_expands_home(self, tmp_path: Path, mocker: MockerFixture) -> None:
154
+ home = tmp_path / "home"
155
+ mocker.patch.dict("os.environ", {"HOME": str(home)})
156
+
157
+ assert resolve_config_path(Path("~/config.toml"), tmp_path / "cwd").unwrap() == home / "config.toml"
158
+
159
+ def test_preserves_application_syntax(self, tmp_path: Path) -> None:
160
+ assert resolve_config_path(Path("@/config.toml"), tmp_path).unwrap() == tmp_path / "@/config.toml"
161
+
162
+ def test_home_expansion_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
163
+ path = Path("~/config.toml")
164
+ mocker.patch.object(Path, "expanduser", side_effect=RuntimeError("home directory unavailable"))
165
+
166
+ caught = resolve_config_path(path, tmp_path).unwrap_err()[0]
167
+ assert isinstance(caught, errors.PathResolutionFailed)
168
+
169
+ assert caught.code == "config_path_resolution_failed"
170
+ assert caught.path == path
171
+ assert isinstance(caught.cause, RuntimeError)
172
+ assert caught.reason == str(caught.cause)
173
+
174
+ def test_reported_symlink_loop(self, tmp_path: Path, mocker: MockerFixture) -> None:
175
+ link = tmp_path / "loop"
176
+ mocker.patch.object(Path, "resolve", side_effect=RuntimeError("symlink loop"))
177
+
178
+ caught = resolve_config_path(link, tmp_path).unwrap_err()[0]
179
+ assert isinstance(caught, errors.PathResolutionFailed)
180
+
181
+ assert caught.code == "config_path_resolution_failed"
182
+ assert caught.path == link
183
+ assert isinstance(caught.cause, (OSError, RuntimeError))
184
+
185
+ def test_filesystem_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
186
+ mocker.patch.object(Path, "resolve", side_effect=PermissionError("access denied"))
187
+
188
+ caught = resolve_config_path(Path("custom.toml"), tmp_path).unwrap_err()[0]
189
+ assert isinstance(caught, errors.PathResolutionFailed)
190
+
191
+ assert caught.code == "config_path_resolution_failed"
192
+ assert caught.path == tmp_path / "custom.toml"
193
+ assert isinstance(caught.cause, PermissionError)
194
+
195
+
196
+ class TestResolveInitConfigPath:
197
+ def test_default_stays_in_cwd_without_reading_or_creating(self, tmp_path: Path) -> None:
198
+ parent_config = tmp_path / "config.toml"
199
+ parent_config.write_text("invalid TOML [", encoding="utf-8")
200
+ cwd = PathInput(tmp_path / "missing")
201
+
202
+ selected = resolve_init_config_path("config.toml", cwd=cwd).unwrap()
203
+
204
+ assert selected == tmp_path / "missing/config.toml"
205
+ assert not cwd.exists()
206
+ assert parent_config.read_text(encoding="utf-8") == "invalid TOML ["
207
+
208
+ @pytest.mark.parametrize("absolute", [False, True])
209
+ def test_explicit_path_overrides_filename(self, tmp_path: Path, absolute: bool) -> None:
210
+ path = tmp_path / "custom.toml" if absolute else Path("nested/../custom.toml")
211
+ cwd = PathInput(tmp_path / "elsewhere" if absolute else tmp_path)
212
+
213
+ selected = resolve_init_config_path("config.toml", path=ProjectConfigPath(path), cwd=cwd).unwrap()
214
+
215
+ assert selected == tmp_path / "custom.toml"
216
+ assert not selected.exists()
217
+
218
+ def test_home_relative_path(self, tmp_path: Path, mocker: MockerFixture) -> None:
219
+ home = tmp_path / "home"
220
+ mocker.patch.dict("os.environ", {"HOME": str(home)})
221
+
222
+ selected = resolve_init_config_path(
223
+ "config.toml", path=ProjectConfigPath(Path("~/custom.toml")), cwd=PathInput(tmp_path)
224
+ ).unwrap()
225
+
226
+ assert selected == home / "custom.toml"
227
+ assert not home.exists()
228
+
229
+ def test_relative_cwd(self, tmp_path: Path) -> None:
230
+ with contextlib.chdir(tmp_path):
231
+ selected = resolve_init_config_path("config.toml", cwd=PathInput(Path("nested"))).unwrap()
232
+
233
+ assert selected == tmp_path / "nested/config.toml"
234
+
235
+ @pytest.mark.parametrize("explicit", [False, True])
236
+ def test_rejects_symlink_without_modifying_existing_target(self, tmp_path: Path, explicit: bool) -> None:
237
+ target = tmp_path / "target.toml"
238
+ target.write_text("original", encoding="utf-8")
239
+ link = ProjectConfigPath(tmp_path / "config.toml")
240
+ link.symlink_to(target)
241
+
242
+ result = resolve_init_config_path("config.toml", path=link if explicit else None, cwd=PathInput(tmp_path))
243
+
244
+ assert result.unwrap_err() == [InvalidProjectPath(path=str(link))]
245
+ assert target.read_text(encoding="utf-8") == "original"
246
+ assert link.is_symlink()
247
+
248
+ @pytest.mark.parametrize("explicit", [False, True])
249
+ def test_resolution_failure_propagates(self, tmp_path: Path, mocker: MockerFixture, explicit: bool) -> None:
250
+ failures: EnvironmentErrors = [
251
+ errors.PathResolutionFailed(path=tmp_path / name, reason="permission denied")
252
+ for name in ("first.toml", "second.toml")
253
+ ]
254
+ mocker.patch("llm_tool_cli.config.files.resolve_config_path", return_value=Err(failures))
255
+
256
+ result = resolve_init_config_path(
257
+ "config.toml", path=ProjectConfigPath(Path("custom.toml")) if explicit else None, cwd=PathInput(tmp_path)
258
+ )
259
+
260
+ assert result.unwrap_err() == failures
261
+
262
+ def test_unexpected_failure_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
263
+ mocker.patch.object(Path, "resolve", side_effect=TypeError("unexpected failure"))
264
+
265
+ with pytest.raises(TypeError, match="unexpected failure"):
266
+ resolve_init_config_path("config.toml", cwd=PathInput(tmp_path))
267
+
268
+
269
+ class TestInitializeConfig:
270
+ @pytest.mark.parametrize("explicit", [False, True])
271
+ def test_broken_symlink_does_not_create_its_target(
272
+ self, tmp_path: Path, config_package: str, explicit: bool
273
+ ) -> None:
274
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
275
+ target = tmp_path / "target.toml"
276
+ path = ProjectConfigPath(tmp_path / "config.toml")
277
+ path.symlink_to(target)
278
+
279
+ result = initialize_config(
280
+ "config.toml",
281
+ package=config_package,
282
+ template="starter.toml",
283
+ cwd=PathInput(tmp_path),
284
+ path=path if explicit else None,
285
+ )
286
+
287
+ assert result.unwrap_err() == [InvalidProjectPath(path=str(path))]
288
+ assert path.is_symlink()
289
+ assert not target.exists()
290
+
291
+ @pytest.mark.parametrize("relative_cwd", [False, True])
292
+ def test_default_path_does_not_reuse_parent_config(
293
+ self, tmp_path: Path, config_package: str, relative_cwd: bool
294
+ ) -> None:
295
+ text = "# café\ninvalid TOML ["
296
+ (tmp_path / "fixtures/starter.toml").write_text(text, encoding="utf-8")
297
+ parent_config = tmp_path / "config.toml"
298
+ parent_config.write_text("original", encoding="utf-8")
299
+ project = tmp_path / "project"
300
+ project.mkdir()
301
+
302
+ with contextlib.chdir(tmp_path):
303
+ selected = initialize_config(
304
+ "config.toml",
305
+ package=config_package,
306
+ template="starter.toml",
307
+ cwd=PathInput(Path("project") if relative_cwd else project),
308
+ ).unwrap()
309
+
310
+ assert selected == project / "config.toml"
311
+ assert selected.read_text(encoding="utf-8") == text
312
+ assert parent_config.read_text(encoding="utf-8") == "original"
313
+
314
+ @pytest.mark.parametrize("absolute", [False, True])
315
+ def test_explicit_path_overrides_filename(self, tmp_path: Path, config_package: str, absolute: bool) -> None:
316
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
317
+ path = tmp_path / "custom.toml" if absolute else Path("custom.toml")
318
+
319
+ selected = initialize_config(
320
+ "config.toml",
321
+ package=config_package,
322
+ template="starter.toml",
323
+ cwd=PathInput(tmp_path / "elsewhere" if absolute else tmp_path),
324
+ path=ProjectConfigPath(path),
325
+ ).unwrap()
326
+
327
+ assert selected == tmp_path / "custom.toml"
328
+ assert selected.read_text(encoding="utf-8") == "version = 1\n"
329
+ assert not (tmp_path / "config.toml").exists()
330
+
331
+ def test_home_relative_path(self, tmp_path: Path, config_package: str, monkeypatch: pytest.MonkeyPatch) -> None:
332
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
333
+ home_dir = tmp_path / "home"
334
+ home_dir.mkdir()
335
+ monkeypatch.setenv("HOME", str(home_dir))
336
+
337
+ selected = initialize_config(
338
+ "config.toml",
339
+ package=config_package,
340
+ template="starter.toml",
341
+ cwd=PathInput(tmp_path),
342
+ path=ProjectConfigPath(Path("~/custom.toml")),
343
+ ).unwrap()
344
+
345
+ assert selected == home_dir / "custom.toml"
346
+ assert selected.read_text(encoding="utf-8") == "version = 1\n"
347
+
348
+ def test_existing_file(self, tmp_path: Path, config_package: str) -> None:
349
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
350
+ path = tmp_path / "config.toml"
351
+ path.write_text("original", encoding="utf-8")
352
+
353
+ failure = initialize_config(
354
+ "config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
355
+ ).unwrap_err()[0]
356
+
357
+ assert isinstance(failure, errors.AlreadyExists)
358
+ assert failure.path == path
359
+ assert path.read_text(encoding="utf-8") == "original"
360
+
361
+ def test_missing_parent_is_not_created(self, tmp_path: Path, config_package: str) -> None:
362
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
363
+ cwd = tmp_path / "missing"
364
+
365
+ failure = initialize_config(
366
+ "config.toml", package=config_package, template="starter.toml", cwd=PathInput(cwd)
367
+ ).unwrap_err()[0]
368
+
369
+ assert isinstance(failure, errors.Unwritable)
370
+ assert failure.path == cwd / "config.toml"
371
+ assert not cwd.exists()
372
+
373
+ def test_concurrent_creation_preserves_existing_config(
374
+ self, tmp_path: Path, config_package: str, mocker: MockerFixture
375
+ ) -> None:
376
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
377
+
378
+ def create_concurrently(path: ProjectConfigPath, text: str) -> Result[None]:
379
+ path.write_text("original", encoding="utf-8")
380
+ return create_config(path, text)
381
+
382
+ mocker.patch("llm_tool_cli.config.files.create_config", side_effect=create_concurrently)
383
+
384
+ failure = initialize_config(
385
+ "config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
386
+ ).unwrap_err()[0]
387
+
388
+ assert isinstance(failure, errors.AlreadyExists)
389
+ assert failure.path == tmp_path / "config.toml"
390
+ assert failure.path.read_text(encoding="utf-8") == "original"
391
+
392
+ def test_resolution_failure_stops_before_template_reading(self, tmp_path: Path, mocker: MockerFixture) -> None:
393
+ failures: EnvironmentErrors = [
394
+ errors.PathResolutionFailed(path=tmp_path / name, reason="permission denied")
395
+ for name in ("first.toml", "second.toml")
396
+ ]
397
+ mocker.patch("llm_tool_cli.config.files.resolve_init_config_path", return_value=Err(failures))
398
+ read_template = mocker.patch("llm_tool_cli.config.files.importlib.resources.files")
399
+
400
+ result = initialize_config("config.toml", package="unused", template="starter.toml", cwd=PathInput(tmp_path))
401
+
402
+ assert result.unwrap_err() == failures
403
+ read_template.assert_not_called()
404
+
405
+ @pytest.mark.parametrize("content", [None, b"\xff"])
406
+ def test_template_read_failure_preserves_cause(
407
+ self, tmp_path: Path, config_package: str, content: bytes | None
408
+ ) -> None:
409
+ if content is not None:
410
+ (tmp_path / "fixtures/starter.toml").write_bytes(content)
411
+
412
+ failure = initialize_config(
413
+ "config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
414
+ ).unwrap_err()[0]
415
+
416
+ assert isinstance(failure, errors.TemplateUnreadable)
417
+ assert isinstance(failure.cause, FileNotFoundError if content is None else UnicodeDecodeError)
418
+ assert failure.path == tmp_path / "config.toml"
419
+ assert failure.template == "starter.toml"
420
+ assert failure.reason == str(failure.cause)
421
+ assert not failure.path.exists()
422
+
423
+ def test_write_failures_propagate(self, tmp_path: Path, config_package: str, mocker: MockerFixture) -> None:
424
+ (tmp_path / "fixtures/starter.toml").write_text("version = 1\n", encoding="utf-8")
425
+ failures: EnvironmentErrors = [errors.Unwritable(path=tmp_path / "config.toml", reason="read-only filesystem")]
426
+ mocker.patch("llm_tool_cli.config.files.create_config", return_value=Err(failures))
427
+
428
+ result = initialize_config(
429
+ "config.toml", package=config_package, template="starter.toml", cwd=PathInput(tmp_path)
430
+ )
431
+
432
+ assert result.unwrap_err() == failures
433
+
434
+
435
+ class TestLocateConfig:
436
+ def test_discovers_nearest_without_reading(self, tmp_path: Path) -> None:
437
+ nested = tmp_path / "nested"
438
+ nested.mkdir()
439
+ (tmp_path / "config.toml").touch()
440
+ nearest = nested / "config.toml"
441
+ nearest.write_text("invalid TOML [", encoding="utf-8")
442
+
443
+ assert locate_config("config.toml", cwd=nested).unwrap() == nearest
444
+
445
+ def test_discovers_in_parent(self, tmp_path: Path) -> None:
446
+ nested = tmp_path / "nested"
447
+ nested.mkdir()
448
+ config_path = tmp_path / "config.toml"
449
+ config_path.touch()
450
+
451
+ assert locate_config("config.toml", path=None, cwd=nested).unwrap() == config_path
452
+
453
+ def test_explicit_missing_path_does_not_fall_back(self, tmp_path: Path) -> None:
454
+ (tmp_path / "config.toml").touch()
455
+
456
+ assert (
457
+ locate_config("config.toml", path=Path("custom.toml"), cwd=tmp_path).unwrap() == tmp_path / "custom.toml"
458
+ )
459
+
460
+ def test_explicit_home_path(self, tmp_path: Path, mocker: MockerFixture) -> None:
461
+ home = tmp_path / "home"
462
+ mocker.patch.dict("os.environ", {"HOME": str(home)})
463
+
464
+ assert locate_config("config.toml", path=Path("~/custom.toml"), cwd=tmp_path).unwrap() == home / "custom.toml"
465
+
466
+ @pytest.mark.parametrize("explicit", [False, True])
467
+ def test_rejects_selected_symlink(self, tmp_path: Path, explicit: bool) -> None:
468
+ target = tmp_path / "target.toml"
469
+ target.touch()
470
+ nested = tmp_path / "nested"
471
+ nested.mkdir()
472
+ link = nested / "config.toml"
473
+ link.symlink_to(target)
474
+
475
+ result = locate_config("config.toml", cwd=nested, path=link if explicit else None)
476
+
477
+ assert result.unwrap_err() == [InvalidProjectPath(path=str(link))]
478
+
479
+ def test_missing(self, tmp_path: Path) -> None:
480
+ filename = f"{tmp_path.name}-missing.toml"
481
+
482
+ caught = locate_config(filename, cwd=tmp_path).unwrap_err()[0]
483
+ assert isinstance(caught, errors.NotFound)
484
+
485
+ assert caught.code == "config_not_found"
486
+ assert caught.path == tmp_path
487
+ assert filename in caught.reason
488
+ assert caught.cause is None
489
+ assert caught.as_record() == {
490
+ "type": "error",
491
+ "code": "config_not_found",
492
+ "message": caught.format_message(),
493
+ "path": str(tmp_path),
494
+ "reason": caught.reason,
495
+ }
496
+
497
+ def test_discovery_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
498
+ mocker.patch.object(Path, "is_file", side_effect=PermissionError("access denied"))
499
+
500
+ caught = locate_config("config.toml", cwd=tmp_path).unwrap_err()[0]
501
+ assert isinstance(caught, errors.DiscoveryFailed)
502
+
503
+ assert caught.path == tmp_path / "config.toml"
504
+ assert isinstance(caught.cause, PermissionError)
505
+
506
+ def test_explicit_resolution_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
507
+ mocker.patch.object(Path, "resolve", side_effect=PermissionError("access denied"))
508
+
509
+ caught = locate_config("config.toml", path=Path("custom.toml"), cwd=tmp_path).unwrap_err()[0]
510
+ assert isinstance(caught, errors.PathResolutionFailed)
511
+
512
+ assert caught.path == tmp_path / "custom.toml"
513
+ assert isinstance(caught.cause, PermissionError)
514
+
515
+
516
+ class TestReadToml:
517
+ def test_toml_1_1_and_utf8(self, tmp_path: Path) -> None:
518
+ path = tmp_path / "config.toml"
519
+ path.write_text('item = {\n label = "café",\n enabled = true,\n}\n', encoding="utf-8")
520
+
521
+ assert read_toml(path).unwrap() == {"item": {"label": "café", "enabled": True}}
522
+
523
+ def test_empty(self, tmp_path: Path) -> None:
524
+ path = tmp_path / "config.toml"
525
+ path.touch()
526
+
527
+ assert read_toml(path).unwrap() == {}
528
+
529
+ def test_invalid_toml(self, tmp_path: Path) -> None:
530
+ path = tmp_path / "config.toml"
531
+ path.write_text("item = [", encoding="utf-8")
532
+
533
+ caught = read_toml(path).unwrap_err()[0]
534
+ assert isinstance(caught, errors.InvalidToml)
535
+
536
+ assert caught.code == "config_invalid_toml"
537
+ assert caught.path == path
538
+ assert isinstance(caught.cause, ValueError)
539
+ assert caught.reason == str(caught.cause)
540
+
541
+ def test_invalid_encoding(self, tmp_path: Path) -> None:
542
+ path = tmp_path / "config.toml"
543
+ path.write_bytes(b'label = "\xff"')
544
+
545
+ caught = read_toml(path).unwrap_err()[0]
546
+ assert isinstance(caught, errors.InvalidEncoding)
547
+
548
+ assert caught.code == "config_invalid_encoding"
549
+ assert caught.path == path
550
+ assert isinstance(caught.cause, UnicodeDecodeError)
551
+
552
+ def test_missing(self, tmp_path: Path) -> None:
553
+ path = tmp_path / "config.toml"
554
+
555
+ caught = read_toml(path).unwrap_err()[0]
556
+ assert isinstance(caught, errors.Unreadable)
557
+
558
+ assert caught.code == "config_unreadable"
559
+ assert caught.path == path
560
+ assert isinstance(caught.cause, FileNotFoundError)
561
+ assert caught.as_record() == {
562
+ "type": "error",
563
+ "code": "config_unreadable",
564
+ "message": caught.format_message(),
565
+ "path": str(path),
566
+ "reason": str(caught.cause),
567
+ }
568
+
569
+ def test_directory(self, tmp_path: Path) -> None:
570
+ caught = read_toml(tmp_path).unwrap_err()[0]
571
+ assert isinstance(caught, errors.Unreadable)
572
+
573
+ assert caught.code == "config_unreadable"
574
+ assert caught.path == tmp_path
575
+ assert isinstance(caught.cause, IsADirectoryError)
576
+
577
+ def test_read_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
578
+ path = tmp_path / "config.toml"
579
+ path.touch()
580
+ mocker.patch("llm_tool_cli.config.files.tomli.load", side_effect=OSError("read failed"))
581
+
582
+ caught = read_toml(path).unwrap_err()[0]
583
+ assert isinstance(caught, errors.Unreadable)
584
+
585
+ assert caught.code == "config_unreadable"
586
+ assert caught.path == path
587
+ assert isinstance(caught.cause, OSError)
588
+
589
+ def test_unexpected_parser_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
590
+ path = tmp_path / "config.toml"
591
+ path.touch()
592
+ mocker.patch("llm_tool_cli.config.files.tomli.load", side_effect=AssertionError("parser defect"))
593
+
594
+ with pytest.raises(AssertionError):
595
+ read_toml(path)
596
+
597
+
598
+ class TestLoadConfig:
599
+ def test_supplied_model(self, tmp_path: Path) -> None:
600
+ class Config(pydantic.BaseModel):
601
+ count: int
602
+ label: str = "default"
603
+
604
+ path = tmp_path / "custom.toml"
605
+ path.write_text('count = "3"\n', encoding="utf-8")
606
+
607
+ loaded: Config = load_config(path, Config).unwrap()
608
+
609
+ assert loaded == Config(count=3)
610
+
611
+ def test_empty_file_uses_model_defaults(self, tmp_path: Path) -> None:
612
+ class Config(pydantic.BaseModel):
613
+ enabled: bool = True
614
+
615
+ path = tmp_path / "custom.toml"
616
+ path.touch()
617
+
618
+ assert load_config(path, Config).unwrap() == Config()
619
+
620
+ @pytest.mark.parametrize("text", ["", 'count = "invalid"\n', "count = -1\n"])
621
+ def test_validation_failure(self, tmp_path: Path, text: str) -> None:
622
+ class Config(pydantic.BaseModel):
623
+ count: int = pydantic.Field(gt=0)
624
+
625
+ path = Path("custom.toml")
626
+ (tmp_path / path).write_text(text, encoding="utf-8")
627
+
628
+ with contextlib.chdir(tmp_path):
629
+ caught = load_config(path, Config).unwrap_err()[0]
630
+
631
+ assert isinstance(caught, errors.ValidationFailed)
632
+
633
+ assert caught.code == "config_validation_failed"
634
+ assert caught.path == path
635
+ assert isinstance(caught.cause, pydantic.ValidationError)
636
+ assert caught.reason == str(caught.cause)
637
+ assert caught.as_record() == {
638
+ "type": "error",
639
+ "code": "config_validation_failed",
640
+ "message": caught.format_message(),
641
+ "path": str(path),
642
+ "reason": str(caught.cause),
643
+ }
644
+
645
+ @pytest.mark.parametrize(
646
+ ("data", "expected_error"),
647
+ [(None, errors.Unreadable), (b"item = [", errors.InvalidToml), (b'label = "\xff"', errors.InvalidEncoding)],
648
+ )
649
+ def test_read_failure(
650
+ self, tmp_path: Path, data: bytes | None, expected_error: type[errors.EnvironmentError]
651
+ ) -> None:
652
+ path = tmp_path / "custom.toml"
653
+ if data is not None:
654
+ path.write_bytes(data)
655
+
656
+ caught = load_config(path, pydantic.BaseModel).unwrap_err()[0]
657
+ assert isinstance(caught, expected_error)
658
+
659
+ assert caught.path == path
660
+ assert caught.cause is not None
661
+
662
+ def test_unexpected_model_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
663
+ path = tmp_path / "custom.toml"
664
+ path.touch()
665
+ mocker.patch.object(pydantic.BaseModel, "model_validate", side_effect=TypeError("model defect"))
666
+
667
+ with pytest.raises(TypeError):
668
+ load_config(path, pydantic.BaseModel)
669
+
670
+
671
+ class TestCreateConfig:
672
+ @pytest.mark.parametrize("text", ["", '# comment\r\nlabel = "café"\r\n'])
673
+ def test_preserves_text(self, tmp_path: Path, text: str) -> None:
674
+ path = ProjectConfigPath(tmp_path / "config.toml")
675
+
676
+ assert create_config(path, text).unwrap() is None
677
+
678
+ assert path.read_bytes() == text.encode("utf-8")
679
+
680
+ def test_refuses_overwrite(self, tmp_path: Path) -> None:
681
+ path = ProjectConfigPath(tmp_path / "config.toml")
682
+ create_config(path, "original").unwrap()
683
+
684
+ caught = create_config(path, "replacement").unwrap_err()[0]
685
+ assert isinstance(caught, errors.AlreadyExists)
686
+
687
+ assert caught.code == "config_already_exists"
688
+ assert caught.path == path
689
+ assert isinstance(caught.cause, FileExistsError)
690
+ assert path.read_text(encoding="utf-8") == "original"
691
+
692
+ def test_existing_directory(self, tmp_path: Path) -> None:
693
+ caught = create_config(ProjectConfigPath(tmp_path), "text").unwrap_err()[0]
694
+ assert isinstance(caught, errors.AlreadyExists)
695
+
696
+ assert caught.code == "config_already_exists"
697
+ assert caught.path == tmp_path
698
+ assert tmp_path.is_dir()
699
+
700
+ @pytest.mark.parametrize("target_exists", [False, True])
701
+ def test_existing_symlink(self, tmp_path: Path, target_exists: bool) -> None:
702
+ target = tmp_path / "target.toml"
703
+ if target_exists:
704
+ target.write_text("original", encoding="utf-8")
705
+ path = ProjectConfigPath(tmp_path / "config.toml")
706
+ path.symlink_to(target)
707
+
708
+ caught = create_config(path, "replacement").unwrap_err()[0]
709
+ assert isinstance(caught, errors.AlreadyExists)
710
+
711
+ assert caught.code == "config_already_exists"
712
+ assert path.is_symlink()
713
+ if target_exists:
714
+ assert target.read_text(encoding="utf-8") == "original"
715
+ else:
716
+ assert not target.exists()
717
+
718
+ def test_missing_parent(self, tmp_path: Path) -> None:
719
+ path = ProjectConfigPath(tmp_path / "missing/config.toml")
720
+
721
+ caught = create_config(path, "text").unwrap_err()[0]
722
+ assert isinstance(caught, errors.Unwritable)
723
+
724
+ assert caught.code == "config_unwritable"
725
+ assert caught.path == path
726
+ assert isinstance(caught.cause, FileNotFoundError)
727
+ assert not path.parent.exists()
728
+
729
+ def test_invalid_text(self, tmp_path: Path) -> None:
730
+ path = ProjectConfigPath(tmp_path / "config.toml")
731
+
732
+ caught = create_config(path, "\ud800").unwrap_err()[0]
733
+ assert isinstance(caught, errors.Unwritable)
734
+
735
+ assert caught.code == "config_unwritable"
736
+ assert caught.path == path
737
+ assert isinstance(caught.cause, UnicodeEncodeError)
738
+ assert not path.exists()
739
+
740
+ def test_write_failure(self, tmp_path: Path, mocker: MockerFixture) -> None:
741
+ path = ProjectConfigPath(tmp_path / "config.toml")
742
+ stream = mocker.patch.object(Path, "open").return_value.__enter__.return_value
743
+ stream.write.side_effect = OSError("write failed")
744
+
745
+ caught = create_config(path, "text").unwrap_err()[0]
746
+ assert isinstance(caught, errors.Unwritable)
747
+
748
+ assert caught.code == "config_unwritable"
749
+ assert caught.path == path
750
+ assert isinstance(caught.cause, OSError)
751
+
752
+
753
+ class TestCreateConfigFromTemplate:
754
+ @pytest.mark.parametrize(
755
+ ("template", "text"),
756
+ [("empty.toml", ""), ("custom.toml", ' # Привет 🌍\nlabel = "café"\n\n'), ("invalid.toml", "item = [")],
757
+ )
758
+ def test_creates_from_selected_package_and_template(
759
+ self, tmp_path: Path, config_package: str, template: str, text: str
760
+ ) -> None:
761
+ (tmp_path / "fixtures" / template).write_text(text, encoding="utf-8")
762
+ path = ProjectConfigPath(Path("custom.toml"))
763
+
764
+ with contextlib.chdir(tmp_path):
765
+ assert create_config_from_template(path, package=config_package, template=template).unwrap() is None
766
+
767
+ assert (tmp_path / path).read_bytes() == text.encode("utf-8")
768
+
769
+ @pytest.mark.parametrize("content", [None, b"\xff"])
770
+ @pytest.mark.parametrize("target_exists", [False, True])
771
+ def test_template_failure_precedes_creation_and_preserves_target(
772
+ self, tmp_path: Path, config_package: str, content: bytes | None, target_exists: bool
773
+ ) -> None:
774
+ if content is not None:
775
+ (tmp_path / "fixtures" / "base.toml").write_bytes(content)
776
+ path = ProjectConfigPath(tmp_path / "custom.toml")
777
+ if target_exists:
778
+ path.write_text("original", encoding="utf-8")
779
+
780
+ failures = create_config_from_template(path, package=config_package, template="base.toml").unwrap_err()
781
+
782
+ assert len(failures) == 1
783
+ failure = failures[0]
784
+ assert isinstance(failure, errors.TemplateUnreadable)
785
+ assert isinstance(failure.cause, FileNotFoundError if content is None else UnicodeDecodeError)
786
+ assert failure.as_record() == {
787
+ "type": "error",
788
+ "code": "config_template_unreadable",
789
+ "message": failure.format_message(),
790
+ "path": str(path),
791
+ "template": "base.toml",
792
+ "reason": str(failure.cause),
793
+ }
794
+ if target_exists:
795
+ assert path.read_text(encoding="utf-8") == "original"
796
+ else:
797
+ assert not path.exists()
798
+
799
+ @pytest.mark.parametrize("target_exists", [False, True])
800
+ def test_creation_failure_keeps_write_diagnostic(
801
+ self, tmp_path: Path, config_package: str, target_exists: bool
802
+ ) -> None:
803
+ (tmp_path / "fixtures" / "base.toml").write_text("replacement", encoding="utf-8")
804
+ path = ProjectConfigPath(tmp_path / "target" / "config.toml")
805
+ if target_exists:
806
+ path.parent.mkdir()
807
+ path.write_text("original", encoding="utf-8")
808
+
809
+ failure = create_config_from_template(path, package=config_package, template="base.toml").unwrap_err()[0]
810
+
811
+ if target_exists:
812
+ assert isinstance(failure, errors.AlreadyExists)
813
+ assert path.read_text(encoding="utf-8") == "original"
814
+ else:
815
+ assert isinstance(failure, errors.Unwritable)
816
+ assert not path.parent.exists()
817
+ assert failure.path == path
818
+ assert isinstance(failure.cause, FileExistsError if target_exists else FileNotFoundError)
819
+
820
+ def test_unexpected_resource_failure_propagates(self, tmp_path: Path, mocker: MockerFixture) -> None:
821
+ mocker.patch("llm_tool_cli.config.files.importlib.resources.files", side_effect=RuntimeError("broken loader"))
822
+
823
+ with pytest.raises(RuntimeError, match="broken loader"):
824
+ create_config_from_template(
825
+ ProjectConfigPath(tmp_path / "config.toml"), package="config_fixture_package", template="base.toml"
826
+ )