feat: add catalog discovery CLI commands (#2360)

* feat: add catalog discovery CLI commands

* fix: address second Copilot review

* fix: address third Copilot review

* fix: align catalog remove with displayed order

* fix: route local catalog config errors to local guidance

* fix: address integration catalog review feedback

* fix: accept numeric string catalog priorities

* fix: align catalog remove with visible entries

* fix: preserve invalid catalog root validation

* fix: include invalid catalog priority value

* fix: preserve falsy catalog root validation

* fix: clarify integration catalog guidance

* fix: align integration catalog list and remove

* fix: align integration catalog edge cases

* fix: clarify catalog error guidance tests

* fix: clarify integration catalog edge cases

* fix: harden integration catalog removal

* fix: validate integration state before catalog search

* fix: reject empty integration catalog URL

* fix: allow catalog remove to clean non-string URLs

* fix: address catalog env and priority review

* fix: align catalog source display names

* fix: align catalog fallback names
This commit is contained in:
Adrian Osorio Blanchard
2026-04-29 08:24:30 -04:00
committed by GitHub
parent 9cf3151a72
commit 1049e17a43
4 changed files with 2071 additions and 21 deletions

View File

@@ -628,3 +628,550 @@ class TestSharedInfraCommandRefs:
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "__SPECKIT_COMMAND_" not in content
class TestIntegrationCatalogDiscoveryCLI:
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
All tests patch `IntegrationCatalog._get_merged_integrations` so no network
or on-disk cache is touched. Adds #2344 coverage without affecting any
existing integration install/switch/uninstall/upgrade behavior.
"""
FAKE_INTEGRATIONS = [
{
"id": "acme-coder",
"name": "Acme Coder",
"version": "2.0.0",
"description": "Community integration for Acme Coder",
"author": "acme-org",
"tags": ["cli", "acme"],
"_catalog_name": "community",
"_install_allowed": False,
},
{
"id": "stellar-agent",
"name": "Stellar Agent",
"version": "1.3.0",
"description": "First-party Stellar agent integration",
"author": "stellar-labs",
"tags": ["ide"],
"_catalog_name": "default",
"_install_allowed": True,
},
]
def _make_project(self, tmp_path):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
return project
def _patch_catalog(self, monkeypatch, integrations=None):
"""Return a stubbed `_get_merged_integrations` that yields *integrations*."""
from specify_cli.integrations.catalog import IntegrationCatalog
data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS)
def fake_merged(self, force_refresh=False):
return data
monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged)
def _invoke(self, argv, cwd):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(cwd)
return runner.invoke(app, argv, catch_exceptions=False)
finally:
os.chdir(old)
# -- Project guard -----------------------------------------------------
def test_search_requires_specify_project(self, tmp_path):
project = tmp_path / "bare"
project.mkdir()
result = self._invoke(["integration", "search"], project)
assert result.exit_code == 1
assert "Not a spec-kit project" in result.output
def test_catalog_list_requires_specify_project(self, tmp_path):
project = tmp_path / "bare"
project.mkdir()
result = self._invoke(["integration", "catalog", "list"], project)
assert result.exit_code == 1
assert "Not a spec-kit project" in result.output
# -- search ------------------------------------------------------------
def test_search_lists_all(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Found 2 integration(s)" in result.output
assert "acme-coder" in result.output
assert "stellar-agent" in result.output
assert "specify integration install stellar-agent" not in normalized_output
assert "Only built-in integration IDs can be installed" in normalized_output
def test_search_validates_integration_json_before_catalog_lookup(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
(project / ".specify" / "integration.json").write_text(
"{bad json\n", encoding="utf-8"
)
from specify_cli.integrations.catalog import IntegrationCatalog
def fail_search(self, **kwargs):
raise AssertionError("catalog search should not be called")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1
assert "contains invalid JSON" in normalized_output
assert "integration.json" in normalized_output
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(["integration", "search", "--tag", "acme"], project)
assert result.exit_code == 0, result.output
assert "Found 1 integration(s)" in result.output
assert "acme-coder" in result.output
assert "stellar-agent" not in result.output
def test_search_filters_by_author(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "search", "--author", "stellar-labs"], project
)
assert result.exit_code == 0, result.output
assert "Found 1 integration(s)" in result.output
assert "stellar-agent" in result.output
def test_search_no_match_hint(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "search", "--tag", "nope"], project
)
assert result.exit_code == 0, result.output
assert "No integrations found" in result.output
assert "specify integration search" in result.output
def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(["integration", "search", "acme"], project)
assert result.exit_code == 0, result.output
# acme-coder is flagged _install_allowed=False, so we should warn
assert "Not directly installable" in result.output
# -- info --------------------------------------------------------------
def test_info_found(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "info", "stellar-agent"], project
)
assert result.exit_code == 0, result.output
assert "Stellar Agent" in result.output
assert "stellar-agent" in result.output
assert "v1.3.0" in result.output
def test_info_not_found(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)
result = self._invoke(
["integration", "info", "does-not-exist"], project
)
assert result.exit_code == 1
assert "not found" in result.output
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Empty catalog, but copilot is a registered built-in.
self._patch_catalog(monkeypatch, integrations=[])
result = self._invoke(["integration", "info", "copilot"], project)
assert result.exit_code == 0, result.output
assert "Built-in integration" in result.output
# -- validation vs network guidance ------------------------------------
def test_search_local_config_error_shows_local_config_tip(
self, tmp_path, monkeypatch
):
"""`integration search` must point at .specify/integration-catalogs.yml
for local-config errors (not the generic 'temporarily unavailable')."""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
# Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError.
cfg = project / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - [bad\n"
cfg.write_text(invalid_yaml, encoding="utf-8")
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "configuration file path shown above" in normalized_output
assert ".specify/integration-catalogs.yml" in normalized_output
assert "~/.specify/integration-catalogs.yml" in normalized_output
assert "temporarily unavailable" not in normalized_output
def test_search_invalid_env_catalog_url_shows_env_tip(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"http://insecure.example.com/catalog.json",
)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output
assert "unset it to use the configured catalog files" in normalized_output
assert ".specify/integration-catalogs.yml" in normalized_output
assert "~/.specify/integration-catalogs.yml" in normalized_output
assert "temporarily unavailable" not in normalized_output
def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ")
from specify_cli.integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogError,
)
def fail_search(self, **kwargs):
raise IntegrationCatalogError("catalog offline")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "temporarily unavailable" in normalized_output
assert (
"SPECKIT_INTEGRATION_CATALOG_URL environment variable"
not in normalized_output
)
def test_info_unknown_with_local_config_error_shows_local_config_tip(
self, tmp_path, monkeypatch
):
"""`integration info <unknown>` falls back to the catalog-error branch
and must show local-config guidance, not 'Try again when online'."""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
cfg = project / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - [bad\n"
cfg.write_text(invalid_yaml, encoding="utf-8")
result = self._invoke(
["integration", "info", "definitely-not-real"], project
)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "configuration file path shown above" in normalized_output
assert ".specify/integration-catalogs.yml" in normalized_output
assert "~/.specify/integration-catalogs.yml" in normalized_output
assert "Try again when online" not in normalized_output
def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"http://insecure.example.com/catalog.json",
)
result = self._invoke(
["integration", "info", "definitely-not-real"], project
)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output
assert "unset it to use the configured catalog files" in normalized_output
assert "Try again when online" not in normalized_output
# -- catalog list / add / remove ---------------------------------------
def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
result = self._invoke(["integration", "catalog", "list"], project)
assert result.exit_code == 0, result.output
assert "Integration Catalog Sources" in result.output
assert "No project-level catalog sources configured" in result.output
assert "Active catalog sources" in result.output
assert "non-removable" in result.output
assert "default" in result.output
assert "community" in result.output
# Built-in defaults are active, but not removable project entries.
assert "[0]" not in result.output
assert "[1]" not in result.output
def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
add_result = self._invoke(
[
"integration",
"catalog",
"add",
"https://new.example.com/catalog.json",
"--name",
"mine",
],
project,
)
assert add_result.exit_code == 0, add_result.output
assert "Catalog source added" in add_result.output
cfg_path = project / ".specify" / "integration-catalogs.yml"
assert cfg_path.exists()
list_result = self._invoke(["integration", "catalog", "list"], project)
assert list_result.exit_code == 0, list_result.output
assert "Project catalog sources" in list_result.output
assert "[0]" in list_result.output
assert "mine" in list_result.output
assert "default" not in list_result.output
assert "community" not in list_result.output
remove_result = self._invoke(
["integration", "catalog", "remove", "0"], project
)
assert remove_result.exit_code == 0, remove_result.output
assert "'mine' removed" in remove_result.output
def test_catalog_list_normalizes_blank_project_catalog_names(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
cfg_path = project / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://null-name.example.com/catalog.json",
"name": None,
},
{
"url": "https://blank-name.example.com/catalog.json",
"name": " ",
},
]
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "catalog", "list"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "[0] catalog-1" in normalized_output
assert "[1] catalog-2" in normalized_output
assert "None" not in normalized_output
def test_catalog_list_env_override_supersedes_project_config(
self, tmp_path, monkeypatch
):
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"https://env.example.com/catalog.json",
)
cfg_path = project / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://project.example.com/catalog.json",
"name": "project",
"priority": 1,
}
]
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "catalog", "list"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output
assert "supersedes configured catalog files" in normalized_output
assert "non-removable" in normalized_output
assert "https://env.example.com/catalog.json" in normalized_output
assert "https://project.example.com/catalog.json" not in normalized_output
assert "[0]" not in normalized_output
def test_catalog_add_strips_whitespace_in_success_output_and_storage(
self, tmp_path, monkeypatch
):
"""Surrounding whitespace in the URL must not appear in the success
message or be persisted to the YAML config."""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
padded_url = " https://padded.example.com/catalog.json "
clean_url = "https://padded.example.com/catalog.json"
add_result = self._invoke(
[
"integration",
"catalog",
"add",
padded_url,
"--name",
"padded",
],
project,
)
assert add_result.exit_code == 0, add_result.output
assert clean_url in add_result.output
assert padded_url not in add_result.output
cfg_path = project / ".specify" / "integration-catalogs.yml"
import yaml as _yaml
data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
urls = [c["url"] for c in data["catalogs"]]
assert clean_url in urls
assert padded_url not in urls
def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
result = self._invoke(
[
"integration",
"catalog",
"add",
"http://insecure.example.com/catalog.json",
],
project,
)
assert result.exit_code == 1
assert "HTTPS" in result.output
def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
url = "https://dup.example.com/catalog.json"
first = self._invoke(
["integration", "catalog", "add", url], project
)
assert first.exit_code == 0, first.output
second = self._invoke(
["integration", "catalog", "add", url], project
)
assert second.exit_code == 1
assert "already configured" in second.output
def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Need a config file for remove to attempt an index lookup
self._invoke(
[
"integration",
"catalog",
"add",
"https://only.example.com/catalog.json",
],
project,
)
result = self._invoke(
["integration", "catalog", "remove", "9"], project
)
assert result.exit_code == 1
assert "out of range" in result.output
def test_catalog_remove_without_config(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
result = self._invoke(
["integration", "catalog", "remove", "0"], project
)
assert result.exit_code == 1
assert "No catalog config" in result.output
def test_catalog_remove_final_entry_restores_defaults(
self, tmp_path, monkeypatch
):
"""End-to-end: add → remove-last-entry → list should not error.
Regression for the flow where a user adds a catalog, removes it, then
runs any follow-up integration command. Without the fix the config
file would be left as `catalogs: []` and every subsequent
`integration` call would fail with "contains no 'catalogs' entries".
"""
project = self._make_project(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
add = self._invoke(
[
"integration",
"catalog",
"add",
"https://only.example.com/catalog.json",
"--name",
"only",
],
project,
)
assert add.exit_code == 0, add.output
remove = self._invoke(
["integration", "catalog", "remove", "0"], project
)
assert remove.exit_code == 0, remove.output
assert "'only' removed" in remove.output
cfg_path = project / ".specify" / "integration-catalogs.yml"
assert not cfg_path.exists(), (
"config file should be deleted when the final catalog is removed"
)
# Follow-up command must succeed and show the built-in defaults,
# not error out on "contains no 'catalogs' entries".
listing = self._invoke(["integration", "catalog", "list"], project)
assert listing.exit_code == 0, listing.output
assert "default" in listing.output
assert "community" in listing.output

View File

@@ -12,6 +12,7 @@ from specify_cli.integrations.catalog import (
IntegrationCatalogError,
IntegrationDescriptor,
IntegrationDescriptorError,
IntegrationValidationError,
)
@@ -115,8 +116,45 @@ class TestActiveCatalogs:
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": []}))
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries") as exc_info:
cat.get_active_catalogs()
assert str(cfg) in str(exc_info.value)
def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text("", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="no 'catalogs' entries"
) as exc_info:
cat.get_active_catalogs()
assert str(cfg) in str(exc_info.value)
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
def test_load_catalog_config_rejects_falsy_non_mapping_roots(
self, tmp_path, monkeypatch, config_content
):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text(config_content, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="expected a YAML mapping at the root",
) as exc_info:
cat.get_active_catalogs()
assert str(cfg) in str(exc_info.value)
# ---------------------------------------------------------------------------
@@ -654,3 +692,838 @@ class TestIntegrationUpgrade:
os.chdir(old)
assert result.exit_code == 0
assert "Nothing to upgrade" in result.output
# ---------------------------------------------------------------------------
# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove)
# ---------------------------------------------------------------------------
class TestCatalogSourceManagement:
"""Unit tests for add_catalog / remove_catalog / get_catalog_configs."""
def _isolate(self, tmp_path, monkeypatch):
"""Point HOME at tmp_path and clear the env override so we read built-ins."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
configs = cat.get_catalog_configs()
assert [c["name"] for c in configs] == ["default", "community"]
assert all(isinstance(c["url"], str) and c["url"] for c in configs)
assert configs[0]["install_allowed"] is True
assert configs[1]["install_allowed"] is False
def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://new.example.com/catalog.json", name="mine")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
assert cfg_path.exists()
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"] == [
{
"name": "mine",
"url": "https://new.example.com/catalog.json",
"priority": 1,
"install_allowed": True,
"description": "",
}
]
# Round-trip: active catalogs should now come from the config file.
active = cat.get_active_catalogs()
assert [e.name for e in active] == ["mine"]
def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text("", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://example.com/catalog.json")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"] == [
{
"name": "catalog-1",
"url": "https://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
"description": "",
}
]
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
def test_add_catalog_rejects_falsy_non_mapping_config_roots(
self, tmp_path, monkeypatch, config_content
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(config_content, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="corrupted.*expected a mapping",
) as exc_info:
cat.add_catalog("https://example.com/catalog.json")
assert str(cfg_path) in str(exc_info.value)
def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json")
cat.add_catalog("https://b.example.com/catalog.json")
data = yaml.safe_load(
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
)
entries = data["catalogs"]
assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"]
assert [e["priority"] for e in entries] == [1, 2]
def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name=" mine ")
cat.add_catalog("https://b.example.com/catalog.json", name=" ")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
entries = data["catalogs"]
assert [e["name"] for e in entries] == ["mine", "catalog-2"]
def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://dup.example.com/catalog.json")
with pytest.raises(IntegrationValidationError, match="already configured"):
cat.add_catalog("https://dup.example.com/catalog.json")
def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
cat.add_catalog("http://insecure.example.com/catalog.json")
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError, match="must be non-empty"):
cat.add_catalog(" ")
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError, match="No catalog config"):
cat.remove_catalog(0)
def test_remove_catalog_happy_path(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name="a")
cat.add_catalog("https://b.example.com/catalog.json", name="b")
removed = cat.remove_catalog(0)
assert removed == "a"
data = yaml.safe_load(
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
)
assert [e["name"] for e in data["catalogs"]] == ["b"]
def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name="a")
with pytest.raises(IntegrationValidationError, match="out of range"):
cat.remove_catalog(5)
with pytest.raises(IntegrationValidationError, match="out of range"):
cat.remove_catalog(-1)
def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info:
cat.add_catalog("https://new.example.com/catalog.json")
assert str(cfg_path) in str(exc_info.value)
def test_add_catalog_rejects_non_list_catalogs_with_config_path(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="invalid 'catalogs' value"
) as exc_info:
cat.add_catalog("https://new.example.com/catalog.json")
assert str(cfg_path) in str(exc_info.value)
def test_add_catalog_rejects_non_mapping_entry_with_config_path(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8"
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Invalid catalog entry at index 0"
) as exc_info:
cat.add_catalog("https://new.example.com/catalog.json")
message = str(exc_info.value)
assert str(cfg_path) in message
assert "expected a mapping" in message
def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": " ", "name": "blank", "priority": 99},
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": 5,
},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://b.example.com/catalog.json", name="b")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][-1]["name"] == "b"
assert data["catalogs"][-1]["priority"] == 6
def test_add_catalog_default_name_ignores_blank_url_entries(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": [{"url": " ", "name": "blank"}]}),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://example.com/catalog.json")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][-1]["name"] == "catalog-1"
def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": "first",
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="'priority' must be an integer, got 'first'",
):
cat.add_catalog("https://b.example.com/catalog.json")
def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": "10",
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://b.example.com/catalog.json", name="b")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][-1]["name"] == "b"
assert data["catalogs"][-1]["priority"] == 11
@pytest.mark.parametrize(
("bad_url", "reason"),
[
("http://insecure.example.com/catalog.json", "HTTPS"),
(123, "HTTPS"),
],
)
def test_add_catalog_rejects_existing_entry_with_bad_url(
self, tmp_path, monkeypatch, bad_url, reason
):
"""A sibling entry with an http:// URL should block a new add."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": bad_url,
"name": "bad",
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError) as exc_info:
cat.add_catalog("https://good.example.com/catalog.json")
message = str(exc_info.value)
assert str(cfg_path) in message
assert "index 0" in message
assert reason in message
def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
"""Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
cfg_path.write_text(invalid_yaml, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Failed to read catalog config"
):
cat.add_catalog("https://b.example.com/catalog.json")
def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
"""Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
cfg_path.write_text(invalid_yaml, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Failed to read catalog config"
):
cat.remove_catalog(0)
def test_add_catalog_defaults_missing_priority_to_index_plus_one(
self, tmp_path, monkeypatch
):
"""Existing entries without `priority` should be treated as idx + 1.
Matches the rule in `_load_catalog_config()`: a valid catalog entry
without an explicit `priority` sorts at `idx + 1`, so the new entry
should get `max(...) + 1` from those derived values.
"""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
# No explicit priority → should be treated as 1
{"url": "https://a.example.com/cat.json", "name": "a"},
# No explicit priority → should be treated as 2
{"url": "https://b.example.com/cat.json", "name": "b"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://c.example.com/cat.json", name="c")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
new_entry = data["catalogs"][-1]
assert new_entry["name"] == "c"
# max(implicit [1, 2]) + 1 == 3
assert new_entry["priority"] == 3
def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch):
"""Whitespace around the incoming URL should be normalized before write."""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog(" https://a.example.com/catalog.json\n", name="a")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json"
def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch):
"""A second add with only whitespace differences must be rejected as a duplicate."""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name="a")
with pytest.raises(IntegrationValidationError, match="already configured"):
cat.add_catalog(" https://a.example.com/catalog.json ")
def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch):
"""An OSError from `Path.unlink` surfaces as IntegrationValidationError."""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://only.example.com/catalog.json", name="only")
from pathlib import Path as _Path
def boom(self, *args, **kwargs):
raise OSError("simulated unlink failure")
monkeypatch.setattr(_Path, "unlink", boom)
with pytest.raises(
IntegrationValidationError, match="Failed to delete catalog config"
):
cat.remove_catalog(0)
def test_remove_catalog_ignores_missing_final_config_during_unlink(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://only.example.com/catalog.json", name="only")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
from pathlib import Path as _Path
original_unlink = _Path.unlink
def delete_first_then_unlink(self, *args, **kwargs):
if self == cfg_path and self.exists():
original_unlink(self)
return original_unlink(self, *args, **kwargs)
monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink)
assert cat.remove_catalog(0) == "only"
assert not cfg_path.exists()
def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch):
"""Hand-edited empty `catalogs:` produces a clear error, not '0--1'."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="contains no catalog entries"
):
cat.remove_catalog(0)
def test_remove_catalog_empty_config_file_gives_clear_error(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text("", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="contains no catalog entries"
):
cat.remove_catalog(0)
def test_remove_catalog_rejects_non_list_catalogs_with_config_path(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="invalid 'catalogs' value"
) as exc_info:
cat.remove_catalog(0)
assert str(cfg_path) in str(exc_info.value)
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
def test_remove_catalog_rejects_falsy_non_mapping_config_roots(
self, tmp_path, monkeypatch, config_content
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(config_content, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="corrupted.*expected a mapping",
) as exc_info:
cat.remove_catalog(0)
assert str(cfg_path) in str(exc_info.value)
def test_remove_last_catalog_deletes_file_and_restores_defaults(
self, tmp_path, monkeypatch
):
"""Removing the final catalog must not leave behind `catalogs: []`.
`_load_catalog_config` treats an empty `catalogs` list as an error,
so writing that file would break every subsequent `integration`
command. Removing the last entry should delete the config file so the
project falls back to built-in defaults.
"""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cat.add_catalog("https://only.example.com/catalog.json", name="only")
assert cfg_path.exists()
assert [e.name for e in cat.get_active_catalogs()] == ["only"]
removed = cat.remove_catalog(0)
assert removed == "only"
assert not cfg_path.exists(), (
"remove_catalog should delete the config file when emptying it"
)
# Follow-up loads fall back to built-in defaults, not an error.
active = cat.get_active_catalogs()
assert [e.name for e in active] == ["default", "community"]
def test_load_catalog_config_raises_validation_error_for_invalid_yaml(
self, tmp_path, monkeypatch
):
"""Local-config problems must surface as IntegrationValidationError so
CLI handlers can route them to local-config (not network) guidance."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
invalid_yaml = "catalogs:\n - [bad\n"
cfg_path.write_text(invalid_yaml, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
# Subclass match: IntegrationValidationError (specifically), not the
# bare IntegrationCatalogError parent that callers used previously.
with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"):
cat.get_active_catalogs()
def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": True,
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Invalid priority|expected integer"
) as exc_info:
cat.get_active_catalogs()
assert str(cfg_path) in str(exc_info.value)
@pytest.mark.parametrize("raw_name", [None, " "])
def test_load_catalog_config_defaults_blank_names(
self, tmp_path, monkeypatch, raw_name
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": " ",
"name": "skipped",
},
{
"url": "https://example.com/catalog.json",
"name": raw_name,
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"]
@pytest.mark.parametrize(
("raw_name", "expected"),
[
(None, "https://one.example.com/c.json"),
(" ", "https://one.example.com/c.json"),
(123, "123"),
],
)
def test_remove_catalog_normalizes_removed_display_name(
self, tmp_path, monkeypatch, raw_name, expected
):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://one.example.com/c.json", name="one")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
data["catalogs"][0]["name"] = raw_name
cfg_path.write_text(yaml.dump(data), encoding="utf-8")
assert cat.remove_catalog(0) == expected
def test_remove_catalog_uses_display_order_with_explicit_priorities(
self, tmp_path, monkeypatch
):
"""`remove_catalog(index)` must remove the entry shown at that index by
`catalog list`, not the entry at that raw YAML position."""
self._isolate(tmp_path, monkeypatch)
# YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15).
# Display (sorted by priority asc): beta (10), gamma (15), alpha (20).
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20},
{"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10},
{"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
# Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0).
removed = cat.remove_catalog(0)
assert removed == "beta"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
remaining_names = [c["name"] for c in data["catalogs"]]
# YAML order is preserved for the survivors; only beta is gone.
assert remaining_names == ["alpha", "gamma"]
def test_remove_catalog_display_order_with_missing_priorities(
self, tmp_path, monkeypatch
):
"""Entries without `priority` default to `idx + 1` (matching
`_load_catalog_config`), so display order tracks YAML order and the
first display entry is the first YAML entry."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://one.example.com/c.json", "name": "one"},
{"url": "https://two.example.com/c.json", "name": "two"},
{"url": "https://three.example.com/c.json", "name": "three"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
# Implicit priorities: one=1, two=2, three=3 → display order matches YAML.
removed = cat.remove_catalog(0)
assert removed == "one"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["two", "three"]
def test_remove_catalog_bool_priority_falls_back_to_yaml_index(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://one.example.com/c.json", "name": "one"},
{
"url": "https://bool.example.com/c.json",
"name": "bool",
"priority": False,
},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "one"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["bool"]
def test_remove_catalog_display_order_skips_blank_url_entries(
self, tmp_path, monkeypatch
):
"""Blank-url entries are not shown by catalog list, so remove skips them too."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": " ", "name": "blank", "priority": 0},
{"url": "https://one.example.com/c.json", "name": "one"},
{"url": "https://two.example.com/c.json", "name": "two"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "one"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["blank", "two"]
def test_remove_catalog_deletes_file_when_only_skipped_entries_remain(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": " ", "name": "blank", "priority": 0},
{"url": "https://one.example.com/c.json", "name": "one"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "one"
assert not cfg_path.exists()
active = cat.get_active_catalogs()
assert [e.name for e in active] == ["default", "community"]
def test_remove_catalog_allows_numeric_url_entry_cleanup(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "numeric-url"
assert not cfg_path.exists()
def test_remove_catalog_errors_when_no_entries_are_removable(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "", "name": "empty"},
{"name": "missing"},
"not-a-mapping",
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="no removable catalog entries",
):
cat.remove_catalog(0)
def test_remove_catalog_display_order_mixes_explicit_and_default(
self, tmp_path, monkeypatch
):
"""An explicit low priority should sort ahead of default-priority
siblings, even if it appears later in the YAML."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
# Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b.
# The blank name should fall back to the removed URL, not raw YAML idx.
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://a.example.com/c.json", "name": "a"},
{"url": "https://b.example.com/c.json", "name": "b"},
{
"url": "https://c.example.com/c.json",
"name": " ",
"priority": 0,
},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "https://c.example.com/c.json"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["a", "b"]