Coverage for tests / unit / tools / core / test_tool_registry.py: 100%
128 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Unit tests for ToolRegistry manifest loading and query methods."""
3from __future__ import annotations
5import json
6import shutil
7from pathlib import Path
9import pytest
10from assertpy import assert_that
12from lintro.tools.core.tool_registry import (
13 ManifestTool,
14 ToolRegistry,
15)
17# ---------------------------------------------------------------------------
18# Fixtures
19# ---------------------------------------------------------------------------
21_FIXTURE_DIR = Path(__file__).parent / "fixtures"
24@pytest.fixture()
25def v2_manifest_path(tmp_path: Path) -> Path:
26 """Copy the v2 manifest fixture to a temp directory and return its path.
28 Args:
29 tmp_path: Pytest built-in temporary directory.
31 Returns:
32 Path to the copied manifest.json.
33 """
34 src = _FIXTURE_DIR / "test_manifest.json"
35 dest = tmp_path / "manifest.json"
36 shutil.copy2(src, dest)
37 return dest
40@pytest.fixture()
41def registry(v2_manifest_path: Path) -> ToolRegistry:
42 """Load a ToolRegistry from the v2 test manifest.
44 Args:
45 v2_manifest_path: Path to the test manifest.
47 Returns:
48 ToolRegistry loaded from the test manifest.
49 """
50 return ToolRegistry._load_from_path(v2_manifest_path)
53# ===========================================================================
54# Manifest loading
55# ===========================================================================
58def test_load_v2_manifest(registry: ToolRegistry) -> None:
59 """Loading a v2 manifest populates tools, profiles, and language_map."""
60 assert_that(len(registry)).is_equal_to(6)
61 assert_that(registry.profile_names).is_length(4)
62 assert_that(registry.language_map).contains_key("python", "docker", "security")
65def test_load_v1_compat(tmp_path: Path) -> None:
66 """A v1 manifest without top-level version_command falls back to install block."""
67 manifest = {
68 "version": 1,
69 "tools": [
70 {
71 "name": "old-tool",
72 "version": "1.0.0",
73 "install": {
74 "type": "pip",
75 "version_command": ["old-tool", "-V"],
76 },
77 },
78 ],
79 }
80 path = tmp_path / "manifest.json"
81 path.write_text(json.dumps(manifest), encoding="utf-8")
83 reg = ToolRegistry._load_from_path(path)
84 tool = reg.get("old-tool")
85 assert_that(tool.version_command).is_equal_to(("old-tool", "-V"))
86 assert_that(tool.category).is_equal_to("bundled")
89def test_load_missing_name_skips_tool(tmp_path: Path) -> None:
90 """A tool entry without a 'name' field is silently skipped."""
91 manifest = {
92 "version": 2,
93 "tools": [
94 {"version": "1.0.0", "install": {"type": "pip"}},
95 {
96 "name": "good",
97 "version": "1.0.0",
98 "install": {"type": "pip"},
99 },
100 ],
101 }
102 path = tmp_path / "manifest.json"
103 path.write_text(json.dumps(manifest), encoding="utf-8")
105 reg = ToolRegistry._load_from_path(path)
106 assert_that(len(reg)).is_equal_to(1)
107 assert_that("good" in reg).is_true()
110def test_load_missing_version_skips_tool(tmp_path: Path) -> None:
111 """A tool entry without a 'version' field is silently skipped."""
112 manifest = {
113 "version": 2,
114 "tools": [
115 {"name": "no-ver", "install": {"type": "pip"}},
116 {
117 "name": "good",
118 "version": "2.0.0",
119 "install": {"type": "pip"},
120 },
121 ],
122 }
123 path = tmp_path / "manifest.json"
124 path.write_text(json.dumps(manifest), encoding="utf-8")
126 reg = ToolRegistry._load_from_path(path)
127 assert_that(len(reg)).is_equal_to(1)
128 assert_that("no-ver" in reg).is_false()
131def test_load_invalid_manifest_version(tmp_path: Path) -> None:
132 """A non-integer manifest version raises ValueError."""
133 manifest = {"version": "not_int", "tools": []}
134 path = tmp_path / "manifest.json"
135 path.write_text(json.dumps(manifest), encoding="utf-8")
137 assert_that(ToolRegistry._load_from_path).raises(ValueError).when_called_with(
138 path,
139 ).is_equal_to("manifest 'version' must be an integer, got 'not_int'")
142def test_parse_tool_entry_invalid_version_command(tmp_path: Path) -> None:
143 """A non-list version_command is treated as an empty list."""
144 manifest = {
145 "version": 2,
146 "tools": [
147 {
148 "name": "bad-cmd",
149 "version": "1.0.0",
150 "install": {"type": "pip"},
151 "version_command": "not-a-list",
152 },
153 ],
154 }
155 path = tmp_path / "manifest.json"
156 path.write_text(json.dumps(manifest), encoding="utf-8")
158 reg = ToolRegistry._load_from_path(path)
159 tool = reg.get("bad-cmd")
160 assert_that(tool.version_command).is_equal_to(())
163# ===========================================================================
164# all_tools
165# ===========================================================================
168def test_all_tools_excludes_dev_by_default(registry: ToolRegistry) -> None:
169 """Dev-tier tools are excluded when include_dev is False (default)."""
170 tools = registry.all_tools()
171 names = [t.name for t in tools]
172 assert_that(names).does_not_contain("dev-tool")
175def test_all_tools_includes_dev(registry: ToolRegistry) -> None:
176 """Dev-tier tools are included when include_dev is True."""
177 tools = registry.all_tools(include_dev=True)
178 names = [t.name for t in tools]
179 assert_that(names).contains("dev-tool")
182def test_all_tools_sorted_by_name(registry: ToolRegistry) -> None:
183 """Returned tools are sorted alphabetically by name."""
184 tools = registry.all_tools(include_dev=True)
185 names = [t.name for t in tools]
186 assert_that(names).is_equal_to(sorted(names))
189# ===========================================================================
190# tools_for_languages
191# ===========================================================================
194def test_tools_for_languages_single(registry: ToolRegistry) -> None:
195 """Passing ['python'] returns python-mapped tools plus security."""
196 tools = registry.tools_for_languages(["python"])
197 names = [t.name for t in tools]
198 assert_that(names).contains("ruff", "mypy", "gitleaks")
201def test_tools_for_languages_multiple(registry: ToolRegistry) -> None:
202 """Passing multiple languages returns the union of their tool sets."""
203 tools = registry.tools_for_languages(["python", "docker"])
204 names = [t.name for t in tools]
205 assert_that(names).contains("ruff", "mypy", "hadolint", "gitleaks")
208def test_tools_for_languages_always_includes_security(
209 registry: ToolRegistry,
210) -> None:
211 """Security tools are always included regardless of language list."""
212 tools = registry.tools_for_languages(["docker"])
213 names = [t.name for t in tools]
214 assert_that(names).contains("gitleaks")
217def test_tools_for_languages_unknown_lang(registry: ToolRegistry) -> None:
218 """An unknown language returns only security tools."""
219 tools = registry.tools_for_languages(["cobol"])
220 names = [t.name for t in tools]
221 assert_that(names).is_equal_to(["gitleaks"])
224# ===========================================================================
225# tools_for_profile
226# ===========================================================================
229def test_tools_for_profile_explicit(registry: ToolRegistry) -> None:
230 """The 'minimal' explicit profile returns exactly the listed tools."""
231 tools = registry.tools_for_profile("minimal")
232 names = [t.name for t in tools]
233 assert_that(names).is_equal_to(["mypy", "ruff"])
236def test_tools_for_profile_auto_detect_with_langs(
237 registry: ToolRegistry,
238) -> None:
239 """The 'recommended' profile with detected_langs delegates to tools_for_languages."""
240 tools = registry.tools_for_profile("recommended", detected_langs=["python"])
241 names = [t.name for t in tools]
242 assert_that(names).contains("ruff", "mypy", "gitleaks")
245def test_tools_for_profile_auto_detect_no_langs_falls_back_to_minimal(
246 registry: ToolRegistry,
247) -> None:
248 """The 'recommended' profile without detected languages falls back to 'minimal'."""
249 tools = registry.tools_for_profile("recommended")
250 names = [t.name for t in tools]
251 assert_that(names).is_equal_to(["mypy", "ruff"])
254def test_tools_for_profile_all(registry: ToolRegistry) -> None:
255 """The 'complete' profile returns every tool, including dev."""
256 tools = registry.tools_for_profile("complete")
257 names = [t.name for t in tools]
258 assert_that(names).contains("dev-tool")
259 assert_that(len(names)).is_equal_to(6)
262def test_tools_for_profile_filter_excludes_formatters(
263 registry: ToolRegistry,
264) -> None:
265 """The 'ci' profile excludes tools whose tags are a subset of ['formatter'].
267 'ruff' has tags ['linter', 'formatter'] so it is kept.
268 'black' has tags ['formatter'] which is a subset — excluded.
269 """
270 tools = registry.tools_for_profile(
271 "ci",
272 detected_langs=["python"],
273 )
274 names = [t.name for t in tools]
275 assert_that(names).contains("ruff")
276 assert_that(names).does_not_contain("black")
279def test_tools_for_profile_unknown_raises(registry: ToolRegistry) -> None:
280 """An unknown profile name raises KeyError."""
281 assert_that(registry.tools_for_profile).raises(KeyError).when_called_with(
282 "nonexistent",
283 )
286# ===========================================================================
287# Query methods
288# ===========================================================================
291def test_get_existing_tool(registry: ToolRegistry) -> None:
292 """get() returns the ManifestTool for a known tool name."""
293 tool = registry.get("ruff")
294 assert_that(tool).is_instance_of(ManifestTool)
295 assert_that(tool.name).is_equal_to("ruff")
296 assert_that(tool.version).is_equal_to("0.14.0")
299def test_get_missing_tool_raises_key_error(registry: ToolRegistry) -> None:
300 """get() raises KeyError for an unknown tool name."""
301 assert_that(registry.get).raises(KeyError).when_called_with("nope")
304def test_get_or_none_existing(registry: ToolRegistry) -> None:
305 """get_or_none() returns the ManifestTool when the tool exists."""
306 tool = registry.get_or_none("mypy")
307 assert_that(tool).is_not_none()
308 # narrow Optional for mypy — assertpy does not perform type narrowing
309 assert tool is not None # noqa: S101
310 assert_that(tool.name).is_equal_to("mypy")
313def test_get_or_none_missing(registry: ToolRegistry) -> None:
314 """get_or_none() returns None when the tool does not exist."""
315 tool = registry.get_or_none("nope")
316 assert_that(tool).is_none()
319def test_contains_true(registry: ToolRegistry) -> None:
320 """__contains__ returns True for a registered tool."""
321 assert_that("ruff" in registry).is_true()
324def test_contains_false(registry: ToolRegistry) -> None:
325 """__contains__ returns False for an unregistered tool."""
326 assert_that("nope" in registry).is_false()
329def test_len(registry: ToolRegistry) -> None:
330 """__len__ returns the total number of tools in the registry."""
331 assert_that(len(registry)).is_equal_to(6)