diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 41fb994726..1f4b097a5c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2377,8 +2377,31 @@ def integration_search( query: Optional[str] = typer.Argument(None, help="Search query (optional)"), tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + markdown: bool = typer.Option( + False, + "--markdown", + help=( + "Output the full built-in integrations table as markdown " + "(ignores filters)" + ), + ), ): - """Search for integrations in the active catalog stack.""" + """Search for integrations in the active catalog stack, or output the built-in reference table with --markdown.""" + if markdown: + if query or tag or author: + typer.echo( + "Warning: --markdown outputs the full built-in integrations table " + "and ignores query/--tag/--author filters.", + err=True, + ) + from .catalog_docs import render_integrations_table + try: + typer.echo(render_integrations_table(), nl=False) + except Exception as exc: + typer.echo(f"Error rendering integrations table: {exc}", err=True) + raise typer.Exit(code=1) + return + from .integrations import INTEGRATION_REGISTRY from .integrations.catalog import ( IntegrationCatalog, diff --git a/src/specify_cli/catalog_docs.py b/src/specify_cli/catalog_docs.py new file mode 100644 index 0000000000..c2ec5fb7bb --- /dev/null +++ b/src/specify_cli/catalog_docs.py @@ -0,0 +1,199 @@ +"""Helpers for rendering the built-in integrations reference table.""" + +from __future__ import annotations + +from typing import Any + + +INTEGRATION_DOC_URLS: dict[str, str | None] = { + "amp": "https://ampcode.com/", + "agy": "https://antigravity.google/", + "auggie": "https://docs.augmentcode.com/cli/overview", + "bob": "https://www.ibm.com/products/bob", + "claude": "https://www.anthropic.com/claude-code", + "codebuddy": "https://www.codebuddy.ai/cli", + "codex": "https://github.com/openai/codex", + "copilot": "https://code.visualstudio.com/", + "cursor-agent": "https://cursor.sh/", + "devin": "https://cli.devin.ai/docs", + "forge": "https://forgecode.dev/", + "gemini": "https://github.com/google-gemini/gemini-cli", + "generic": None, + "goose": "https://block.github.io/goose/", + "iflow": "https://docs.iflow.cn/en/cli/quickstart", + "junie": "https://junie.jetbrains.com/", + "kilocode": "https://github.com/Kilo-Org/kilocode", + "kimi": "https://code.kimi.com/", + "kiro-cli": "https://kiro.dev/docs/cli/", + "lingma": "https://lingma.aliyun.com/", + "opencode": "https://opencode.ai/", + "pi": "https://pi.dev", + "qodercli": "https://qoder.com/cli", + "qwen": "https://github.com/QwenLM/qwen-code", + "roo": "https://roocode.com/", + "shai": "https://github.com/ovh/shai", + "tabnine": "https://docs.tabnine.com/main/getting-started/tabnine-cli", + "trae": "https://www.trae.ai/", + "vibe": "https://github.com/mistralai/mistral-vibe", + "windsurf": "https://windsurf.com/", +} + +INTEGRATION_LABEL_OVERRIDES: dict[str, str] = { + "agy": "Antigravity (agy)", + "codebuddy": "CodeBuddy CLI", + "generic": "Generic", + "shai": "SHAI (OVHcloud)", +} + +INTEGRATION_NOTES: dict[str, str] = { + "agy": "Skills-based integration; skills are installed automatically", + "claude": "Skills-based integration; installs skills in `.claude/skills`", + "codex": ( + "Skills-based integration; installs skills into `.agents/skills` " + "and invokes them as `$speckit-`" + ), + "bob": "IDE-based agent", + "devin": ( + "Skills-based integration; installs skills into `.devin/skills/` " + "and invokes them as `/speckit-`" + ), + "goose": "Uses YAML recipe format in `.goose/recipes/`", + "kimi": ( + "Skills-based integration; supports `--migrate-legacy` " + "for dotted→hyphenated directory migration" + ), + "kiro-cli": ( + "Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, " + "so Spec Kit ships a prose fallback at render time " + "(see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) " + "and issue [#1926](https://github.com/github/spec-kit/issues/1926)). " + "Alias: `--integration kiro`" + ), + "lingma": "Skills-based integration; skills are installed automatically", + "pi": ( + "Pi doesn't have MCP support out of the box, so `taskstoissues` " + "won't work as intended. MCP support can be added via " + "[extensions](https://github.com/badlogic/pi-mono/tree/main/" + "packages/coding-agent#extensions)" + ), + "generic": ( + "Bring your own agent — use `--integration generic " + "--integration-options=\"--commands-dir \"` " + "for AI coding agents not listed above" + ), + "trae": "Skills-based integration; skills are installed automatically", +} + + +def render_cell(value: str) -> str: + r"""Escape markdown special characters (pipes) and normalize newlines to spaces. + + This ensures table cells remain valid markdown even if they contain + pipes (escaped as \|) or carriage returns (normalized to spaces). + """ + value = value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ") + return value.replace("|", "\\|") + + +def escape_url_for_markdown_link(url: str) -> str: + """Escape characters that can break Markdown link syntax. + + Escapes `)` and `|` which can terminate or corrupt the link destination. + """ + return url.replace(")", "\\)").replace("|", "\\|") + + +def _get_integration_registry() -> dict[str, Any]: + from specify_cli.integrations import INTEGRATION_REGISTRY + + return INTEGRATION_REGISTRY + + +def list_integrations_for_docs( + warn_on_missing: bool = False, + warn_on_extra: bool = False, +) -> list[tuple[str, str, str | None, str]]: + """List all integrations with their documentation URLs and notes. + + Returns all integrations in the registry. Missing entries in INTEGRATION_DOC_URLS + default to None; if `warn_on_missing` is True, emits a warning for these. + If `warn_on_extra` is True, emits a warning for stale keys in the doc maps that + are no longer in the registry. Missing notes entries default to empty string. + """ + registry = _get_integration_registry() + registry_keys = set(registry) + + # Warn if there are integrations missing from INTEGRATION_DOC_URLS (when enabled) + missing = sorted(registry_keys - set(INTEGRATION_DOC_URLS)) + if missing and warn_on_missing: + import warnings + warnings.warn( + f"Integration(s) missing from INTEGRATION_DOC_URLS: " + f"{', '.join(missing)}. They will be included in the docs table " + "without documentation links. Add them to INTEGRATION_DOC_URLS in " + "catalog_docs.py if a link should be available.", + stacklevel=2 + ) + + # Warn if there are stale keys in doc maps not in the registry (when enabled) + if warn_on_extra: + extra_in_urls = sorted(set(INTEGRATION_DOC_URLS) - registry_keys) + extra_in_labels = sorted( + set(INTEGRATION_LABEL_OVERRIDES) - registry_keys + ) + extra_in_notes = sorted(set(INTEGRATION_NOTES) - registry_keys) + extra_keys = extra_in_urls or extra_in_labels or extra_in_notes + if extra_keys: + import warnings + stale_keys = sorted( + set(extra_in_urls + extra_in_labels + extra_in_notes) + ) + warnings.warn( + f"Stale key(s) found in doc maps (no longer in registry): " + f"{stale_keys}. Consider removing them from " + "INTEGRATION_DOC_URLS, INTEGRATION_LABEL_OVERRIDES, and " + "INTEGRATION_NOTES.", + stacklevel=2 + ) + + rows: list[tuple[str, str, str | None, str]] = [] + + for key, integration in registry.items(): + config = getattr(integration, "config", {}) + if not isinstance(config, dict): + config = {} + label = INTEGRATION_LABEL_OVERRIDES.get(key, str(config.get("name") or key)) + url = INTEGRATION_DOC_URLS.get(key) # None if not in map + notes = INTEGRATION_NOTES.get(key, "") + rows.append((key, label, url, notes)) + + return sorted(rows, key=lambda r: r[0]) + + +def render_integrations_table() -> str: + """Render the built-in integrations reference table as markdown.""" + table_rows: list[list[str]] = [] + + for key, label, url, notes in list_integrations_for_docs(): + # Escape raw field values *before* composing Markdown syntax so that + # a pipe inside a label or notes doesn't break a link target. + safe_label = render_cell(label) + safe_notes = render_cell(notes) + safe_url = escape_url_for_markdown_link(url) if url else None + agent = ( + f"[{safe_label}]({safe_url})" + if safe_url + else safe_label + ) + table_rows.append([agent, f"`{key}`", safe_notes]) + + headers = ("Agent", "Key", "Notes") + + def render_row(values: list[str]) -> str: + # Values are already escaped; do not re-apply render_cell here. + return "| " + " | ".join(values) + " |" + + separator = "| " + " | ".join("---" for _ in headers) + " |" + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in table_rows) + return "\n".join(lines) + "\n" diff --git a/src/specify_cli/community_catalog_docs.py b/src/specify_cli/community_catalog_docs.py new file mode 100644 index 0000000000..a5ca769e7b --- /dev/null +++ b/src/specify_cli/community_catalog_docs.py @@ -0,0 +1,98 @@ +"""Helpers for rendering the community extensions reference table.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .catalog_docs import escape_url_for_markdown_link, render_cell + + +ROOT_DIR = Path(__file__).resolve().parents[2] +COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json" + + +def _format_tags(tags: Any) -> str: + if not isinstance(tags, list) or not tags: + return "—" + # Clean first, then filter: a tag of " | " would pass str(tag).strip() but produce + # an empty backtick span after pipe removal, so filter on the cleaned value. + cleaned = [f"`{c}`" for tag in tags if (c := str(tag).replace("|", "").strip())] + return ", ".join(cleaned) if cleaned else "—" + + +def list_community_extensions( + path: Path = COMMUNITY_CATALOG_PATH, +) -> list[dict[str, Any]]: + """Return community extensions sorted alphabetically by name then ID.""" + if not path.exists(): + raise FileNotFoundError( + f"Community catalog not found at {path}. " + "Ensure the repository checkout includes the extensions/ directory." + ) + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"Expected {path} to contain a JSON object") + extensions = data.get("extensions") + if not isinstance(extensions, dict): + raise ValueError(f"Expected {path} to contain an 'extensions' object") + + rows: list[dict[str, Any]] = [] + for ext_id, ext in extensions.items(): + if not isinstance(ext, dict): + raise ValueError(f"Community extension {ext_id!r} must be a mapping") + rows.append( + { + "name": str(ext.get("name") or ext_id), + "id": str(ext.get("id") or ext_id), + "description": str(ext.get("description") or ""), + "tags": ext.get("tags") or [], + "verified": "Yes" if bool(ext.get("verified")) else "No", + "repository": str(ext.get("repository") or ""), + } + ) + + return sorted( + rows, + key=lambda row: (row["name"].casefold(), row["id"].casefold()), + ) + + +def render_community_extensions_table(path: Path = COMMUNITY_CATALOG_PATH) -> str: + """Render the community extensions table from catalog.community.json.""" + rows = list_community_extensions(path=path) + if not rows: + raise ValueError("Community catalog has no extensions") + + table_rows: list[list[str]] = [] + for row in rows: + # Escape raw field values *before* composing Markdown syntax so that + # a pipe inside a name or description doesn't break a link target. + safe_name = render_cell(row["name"]) + safe_repo = escape_url_for_markdown_link(row["repository"]) + link = ( + f"[{safe_name}]({safe_repo})" + if row["repository"] + else safe_name + ) + table_rows.append( + [ + link, + f"`{render_cell(row['id'])}`", + render_cell(row["description"]), + _format_tags(row["tags"]), + row["verified"], + ] + ) + + headers = ("Extension", "ID", "Description", "Tags", "Verified") + + def render_row(values: list[str]) -> str: + # Values are already escaped; do not re-apply render_cell here. + return "| " + " | ".join(values) + " |" + + separator = "| " + " | ".join("---" for _ in headers) + " |" + lines = [render_row(list(headers)), separator] + lines.extend(render_row(row) for row in table_rows) + return "\n".join(lines) + "\n" diff --git a/tests/test_catalog_docs.py b/tests/test_catalog_docs.py new file mode 100644 index 0000000000..5da38dbca6 --- /dev/null +++ b/tests/test_catalog_docs.py @@ -0,0 +1,323 @@ +"""Tests for the integration registry documentation generation.""" + +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from specify_cli.catalog_docs import ( + escape_url_for_markdown_link, + render_cell, + list_integrations_for_docs, + render_integrations_table, +) +from specify_cli import app + + +runner = CliRunner() + + +@contextmanager +def _get_catalog_docs_patches(): + """Context manager that applies mocked registry and doc maps for tests.""" + + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } + fake_label_overrides = {} + fake_notes = {"copilot": "Test note"} + + with ExitStack() as stack: + stack.enter_context( + patch( + "specify_cli.catalog_docs._get_integration_registry", + return_value=fake_registry, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls) + ) + stack.enter_context( + patch( + "specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", + fake_label_overrides, + ) + ) + stack.enter_context( + patch("specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes) + ) + yield + + +def test_integrations_table_renders(): + table = render_integrations_table() + lines = table.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_render_cell_escapes_pipes_and_normalizes_newlines(): + assert render_cell("a|b") == "a\\|b" + assert render_cell("a\nb") == "a b" + assert render_cell("a\r\nb") == "a b" + assert render_cell("a\rb") == "a b" + assert render_cell("a|b\nc") == "a\\|b c" + + +def test_escape_url_for_markdown_link(): + """Test that URLs with special characters are properly escaped for Markdown links.""" + # URLs containing ) and | should be escaped + assert escape_url_for_markdown_link("https://example.com/path)") == ( + "https://example.com/path\\)" + ) + assert escape_url_for_markdown_link("https://example.com/path|query") == ( + "https://example.com/path\\|query" + ) + assert escape_url_for_markdown_link("https://example.com/path)|query") == ( + "https://example.com/path\\)\\|query" + ) + # URLs without special characters should be unchanged + assert escape_url_for_markdown_link("https://example.com/path") == ( + "https://example.com/path" + ) + + +def test_integrations_docs_label_and_url_sources(): + """Test using mocked registry/doc maps to avoid test brittleness.""" + # Create a minimal fake registry with two known integrations + fake_registry = { + "copilot": MagicMock(config={"name": "GitHub Copilot"}), + "codex": MagicMock(config={"name": "Codex CLI"}), + } + + # Mock the doc maps to only contain entries for the fake registry + fake_doc_urls = { + "copilot": "https://code.visualstudio.com/", + "codex": "https://github.com/openai/codex", + } + fake_label_overrides = {} + fake_notes = {} + + patch_registry = patch( + "specify_cli.catalog_docs._get_integration_registry", + return_value=fake_registry, + ) + patch_urls = patch( + "specify_cli.catalog_docs.INTEGRATION_DOC_URLS", fake_doc_urls + ) + patch_labels = patch( + "specify_cli.catalog_docs.INTEGRATION_LABEL_OVERRIDES", + fake_label_overrides, + ) + patch_notes = patch( + "specify_cli.catalog_docs.INTEGRATION_NOTES", fake_notes + ) + + with patch_registry, patch_urls, patch_labels, patch_notes: + rows = { + key: (label, url) + for key, label, url, _notes in list_integrations_for_docs() + } + assert rows["copilot"][0] == "GitHub Copilot" + assert rows["copilot"][1] == "https://code.visualstudio.com/" + assert rows["codex"][0] == "Codex CLI" + assert rows["codex"][1] == "https://github.com/openai/codex" + + +def test_cli_integration_search_markdown_success(): + """Test that `integration search --markdown` outputs the markdown table.""" + with _get_catalog_docs_patches(): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + lines = result.stdout.splitlines() + assert len(lines) > 2 # At least header, separator, and one data row + assert lines[0] == "| Agent | Key | Notes |" + assert lines[1] == "| --- | --- | --- |" + + +def test_cli_integration_search_markdown_with_filters_warns(): + """Test that `integration search --markdown` with filters warns.""" + with _get_catalog_docs_patches(): + result = runner.invoke( + app, + [ + "integration", + "search", + "test-query", + "--markdown", + "--tag", + "some-tag", + ], + ) + assert result.exit_code == 0 + # Check for the specific Typer warning message + assert "ignores query/--tag/--author filters" in result.stderr + lines = result.stdout.splitlines() + assert lines[0] == "| Agent | Key | Notes |" + + +def test_cli_integration_search_markdown_stdout_is_clean(): + """Test that stdout contains only the markdown table with proper format.""" + with _get_catalog_docs_patches(): + result = runner.invoke(app, ["integration", "search", "--markdown"]) + assert result.exit_code == 0 + stdout = result.stdout + lines = stdout.splitlines() + # Verify markdown table header is present + assert len(lines) > 1 + assert lines[0] == "| Agent | Key | Notes |" + # Ensure stderr has no error messages + assert "error" not in result.stderr.lower() + + +def test_docs_reference_integrations_md_stays_in_sync(): + """Regression test: committed docs/reference/integrations.md stays in sync. + + This ensures that the integration reference docs file contains the exact + list of integrations defined in the registry. + If this test fails, run: specify integration search --markdown + and update the table in docs/reference/integrations.md accordingly. + """ + import pytest + from pathlib import Path + + # Find the committed integrations.md file + repo_root = Path(__file__).parent.parent + docs_file = repo_root / "docs" / "reference" / "integrations.md" + + if not docs_file.exists(): + pytest.skip( + f"Integration reference docs not found at {docs_file}. " + "Skipping sync test (expected in CI, acceptable in isolated " + "test environments)." + ) + + # Read the committed file with explicit UTF-8 encoding + with open(docs_file, encoding="utf-8") as f: + committed_content = f.read() + + # Extract rows from the H2 section ## Supported AI Coding Agents + def parse_first_markdown_table(text: str) -> set[tuple[str, str, str]]: + """Parse the first markdown table in a section, respecting escaped pipes.""" + lines = text.splitlines() + in_target_section = False + in_table = False + rows = [] + + def split_markdown_table_row(line: str) -> list[str]: + parts = [] + current = "" + backslash_run = 0 + for char in line: + if char == "\\": + backslash_run += 1 + current += char + continue + if char == "|" and backslash_run % 2 == 0: + parts.append(current.strip()) + current = "" + else: + current += char + backslash_run = 0 + parts.append(current.strip()) + if parts and parts[0] == "": + parts = parts[1:] + if parts and parts[-1] == "": + parts = parts[:-1] + return parts + + for line in lines: + if line.startswith("## Supported AI Coding Agents"): + in_target_section = True + continue + if in_target_section: + if line.startswith("## "): + break + if line.strip().startswith("|"): + in_table = True + parts = split_markdown_table_row(line) + + if ( + all(p.startswith("---") or p == "" for p in parts) + or parts == ["Agent", "Key", "Notes"] + ): + continue + + # Validate we have 3 columns + assert ( + len(parts) == 3 + ), f"Malformed row in integrations.md: {line!r} (expected 3 columns, got {len(parts)})" + + rows.append((parts[0], parts[1], parts[2])) + elif in_table: + break + return set(rows) + + def parse_markdown_table_rows(text: str) -> set[tuple[str, str, str]]: + """Parse markdown table rows, respecting escaped pipes.""" + rows = [] + + def split_markdown_table_row(line: str) -> list[str]: + parts = [] + current = "" + backslash_run = 0 + for char in line: + if char == "\\": + backslash_run += 1 + current += char + continue + if char == "|" and backslash_run % 2 == 0: + parts.append(current.strip()) + current = "" + else: + current += char + backslash_run = 0 + parts.append(current.strip()) + if parts and parts[0] == "": + parts = parts[1:] + if parts and parts[-1] == "": + parts = parts[:-1] + return parts + + for line in text.splitlines(): + if not line.strip().startswith("|"): + continue + + parts = split_markdown_table_row(line) + + # Skip header and separator rows + if ( + all(p.startswith("---") or p == "" for p in parts) + or parts == ["Agent", "Key", "Notes"] + ): + continue + + # Validate we have the expected 3 columns + if len(parts) != 3: + continue + + rows.append((parts[0], parts[1], parts[2])) + return set(rows) + + committed_rows = parse_first_markdown_table(committed_content) + generated_table = render_integrations_table() + generated_rows = parse_markdown_table_rows(generated_table) + + # Assert they are in perfect sync + diff_missing = generated_rows - committed_rows + diff_extra = committed_rows - generated_rows + + error_msg = ( + "The committed integrations.md table is out of sync with the registry.\n" + f"Missing from docs: {diff_missing}\n" + f"Extra in docs: {diff_extra}\n" + "To update the docs table, run: specify integration search --markdown" + ) + assert not diff_missing and not diff_extra, error_msg diff --git a/tests/test_community_catalog_docs.py b/tests/test_community_catalog_docs.py new file mode 100644 index 0000000000..a5beca6bcf --- /dev/null +++ b/tests/test_community_catalog_docs.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from specify_cli.community_catalog_docs import list_community_extensions, render_community_extensions_table + + +def _write_catalog(tmp_path: Path, extensions: dict) -> Path: + p = tmp_path / "catalog.community.json" + p.write_text(json.dumps({"extensions": extensions}), encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# Happy-path tests against the real catalog +# --------------------------------------------------------------------------- + +def test_community_extensions_table_renders() -> None: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + table = render_community_extensions_table() + assert "| Extension" in table + assert "| ID" in table + assert "| Description" in table + assert "| Tags" in table + assert "| Verified" in table + + +def test_community_extensions_are_sorted_by_name() -> None: + from specify_cli.community_catalog_docs import COMMUNITY_CATALOG_PATH + if not COMMUNITY_CATALOG_PATH.exists(): + pytest.skip( + f"Community catalog not found at {COMMUNITY_CATALOG_PATH}. " + "Skipping (expected when running from sdist/wheel)." + ) + rows = list_community_extensions() + names = [row["name"] for row in rows] + assert names == sorted(names, key=str.casefold) + + +# --------------------------------------------------------------------------- +# Edge-case tests using synthetic catalogs +# --------------------------------------------------------------------------- + +def test_missing_catalog_file(tmp_path: Path) -> None: + with pytest.raises( + FileNotFoundError, + match="Ensure the repository checkout includes the extensions/ directory", + ): + list_community_extensions(path=tmp_path / "missing.json") + + +def test_malformed_json(tmp_path: Path) -> None: + bad = tmp_path / "bad.json" + bad.write_text("not valid json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + list_community_extensions(path=bad) + + +def test_non_dict_root(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps([{"id": "foo"}]), encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + list_community_extensions(path=f) + + +def test_missing_extensions_key(tmp_path: Path) -> None: + f = tmp_path / "catalog.json" + f.write_text(json.dumps({"other": {}}), encoding="utf-8") + with pytest.raises(ValueError, match="'extensions' object"): + list_community_extensions(path=f) + + +def test_non_dict_extension_value(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {"foo": "not-a-dict"}) + with pytest.raises(ValueError, match="must be a mapping"): + list_community_extensions(path=f) + + +def test_empty_catalog_raises(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, {}) + with pytest.raises(ValueError, match="no extensions"): + render_community_extensions_table(path=f) + + +def test_extension_without_repository(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "id": "foo", "description": "A foo tool", "tags": [], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "Foo" in table + assert "[Foo](" not in table # plain name, no link + + +def test_tags_containing_pipe_do_not_break_table(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + # No "id" field — exercises ext_id fallback; tag has pipe — exercises stripping + "foo": {"name": "Foo", "description": "", "tags": ["foo|bar"], "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + # pipe stripped from tag value + assert "`foobar`" in table + # id falls back to the dict key when "id" field is absent + assert "`foo`" in table + # row is well-formed: 5-column table has exactly 6 pipe separators per row + foo_row = next(line for line in table.split("\n") if line.startswith("| ") and "Foo" in line) + assert foo_row.count("|") == 6 + + +def test_non_list_tags_renders_em_dash(tmp_path: Path) -> None: + f = _write_catalog(tmp_path, { + "foo": {"name": "Foo", "description": "", "tags": "not-a-list", "verified": False, "repository": ""}, + }) + table = render_community_extensions_table(path=f) + assert "—" in table + + +def test_url_escaping_in_repository_links(tmp_path: Path) -> None: + """Test that URLs with `)` and `|` are properly escaped in markdown links.""" + f = _write_catalog(tmp_path, { + "foo": { + "name": "Foo", + "description": "", + "tags": [], + "verified": False, + "repository": "https://example.com/repo?x=1)&y=2|bad", # Contains ) and | + }, + }) + table = render_community_extensions_table(path=f) + # The URL should be escaped: ) → \) and | → \| + assert "[Foo](https://example.com/repo?x=1\\)&y=2\\|bad)" in table