Skip to content

Commit 7faa758

Browse files
authored
Merge pull request #39 from netresearch/fix/debian-package-naming
fix(catalog): resolve Debian/Ubuntu package naming mismatches
2 parents 43eac2e + 1200ac6 commit 7faa758

7 files changed

Lines changed: 270 additions & 2 deletions

File tree

catalog/bat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"homepage": "https://github.com/sharkdp/bat",
77
"github_repo": "sharkdp/bat",
88
"binary_name": "bat",
9+
"candidates": ["bat", "batcat"],
910
"download_url_template": "https://github.com/sharkdp/bat/releases/download/{version}/bat-{version}-{arch}-unknown-linux-musl.tar.gz",
1011
"arch_map": {
1112
"x86_64": "x86_64",

catalog/delta.json

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "delta",
33
"category": "git",
4-
"install_method": "github_release_binary",
4+
"install_method": "auto",
55
"description": "A syntax-highlighting pager for git, diff, and grep output",
66
"homepage": "https://github.com/dandavison/delta",
77
"github_repo": "dandavison/delta",
@@ -12,6 +12,30 @@
1212
"aarch64": "aarch64",
1313
"armv7l": "armv7"
1414
},
15+
"available_methods": [
16+
{
17+
"method": "github_release_binary",
18+
"priority": 1,
19+
"config": {
20+
"repo": "dandavison/delta",
21+
"asset_pattern": "delta-.*-x86_64-unknown-linux-musl.tar.gz"
22+
}
23+
},
24+
{
25+
"method": "cargo",
26+
"priority": 2,
27+
"config": {
28+
"crate": "git-delta"
29+
}
30+
},
31+
{
32+
"method": "apt",
33+
"priority": 3,
34+
"config": {
35+
"package": "git-delta"
36+
}
37+
}
38+
],
1539
"tags": [
1640
"core"
1741
]

catalog/fd.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"homepage": "https://github.com/sharkdp/fd",
77
"github_repo": "sharkdp/fd",
88
"binary_name": "fd",
9+
"candidates": ["fd", "fdfind"],
910
"download_url_template": "https://github.com/sharkdp/fd/releases/download/{version}/fd-{version}-{arch}-unknown-linux-musl.tar.gz",
1011
"arch_map": {
1112
"x86_64": "x86_64",

cli_audit/catalog.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,41 @@ def get_package_manager_tools(self) -> list[ToolCatalogEntry]:
301301
]
302302

303303

304+
def resolve_apt_package_name(tool_name: str) -> str:
305+
"""Resolve the apt package name for a tool from its catalog entry.
306+
307+
Reads available_methods from the catalog JSON and returns the apt
308+
package name if configured, otherwise falls back to tool_name.
309+
310+
Args:
311+
tool_name: Tool name to resolve
312+
313+
Returns:
314+
The apt package name from the catalog, or tool_name as fallback
315+
"""
316+
catalog_path = Path(__file__).parent.parent / "catalog" / f"{tool_name}.json"
317+
if not catalog_path.exists():
318+
return tool_name
319+
try:
320+
with open(catalog_path) as f:
321+
data = json.load(f)
322+
# Check available_methods for apt entry (modern format)
323+
for method in data.get("available_methods", []):
324+
if method.get("method") == "apt":
325+
return method.get("config", {}).get("package", tool_name)
326+
# Check legacy package_managers field
327+
pkg_mgrs = data.get("package_managers", {})
328+
if "apt" in pkg_mgrs:
329+
return pkg_mgrs["apt"]
330+
# Check legacy packages field
331+
packages = data.get("packages", {})
332+
if "apt" in packages:
333+
return packages["apt"]
334+
except json.JSONDecodeError:
335+
pass
336+
return tool_name
337+
338+
304339
def detect_package_manager() -> tuple[str, str] | None:
305340
"""Detect the current OS package manager and upgrade command.
306341

cli_audit/upgrade.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,10 @@ def get_available_version(
308308
return version_str
309309

310310
elif package_manager == "apt":
311+
from .catalog import resolve_apt_package_name
312+
apt_pkg = resolve_apt_package_name(tool_name)
311313
result = subprocess.run(
312-
["apt-cache", "policy", tool_name],
314+
["apt-cache", "policy", apt_pkg],
313315
capture_output=True,
314316
text=True,
315317
timeout=10,

tests/test_update_fixes.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,83 @@ def test_catalog_install_method_has_installer(self, catalog_name):
540540
assert installer.exists(), (
541541
f"{catalog_name}: installer {installer} not found for install_method={method}"
542542
)
543+
544+
545+
# ===========================================================================
546+
# 12. Debian/Ubuntu package naming mismatches (#35)
547+
# ===========================================================================
548+
549+
class TestCatalogDebianNaming:
550+
"""Tests for #35: Debian/Ubuntu package naming mismatches."""
551+
552+
def test_bat_has_batcat_candidate(self):
553+
"""bat.json must include 'batcat' in candidates for Debian detection."""
554+
with open(CATALOG_DIR / "bat.json") as f:
555+
data = json.load(f)
556+
candidates = data.get("candidates", [])
557+
assert "batcat" in candidates, (
558+
"bat.json should have 'batcat' in candidates for Debian/Ubuntu"
559+
)
560+
assert "bat" in candidates, (
561+
"bat.json should also keep 'bat' in candidates"
562+
)
563+
564+
def test_fd_has_fdfind_candidate(self):
565+
"""fd.json must include 'fdfind' in candidates for Debian detection."""
566+
with open(CATALOG_DIR / "fd.json") as f:
567+
data = json.load(f)
568+
candidates = data.get("candidates", [])
569+
assert "fdfind" in candidates, (
570+
"fd.json should have 'fdfind' in candidates for Debian/Ubuntu"
571+
)
572+
assert "fd" in candidates, (
573+
"fd.json should also keep 'fd' in candidates"
574+
)
575+
576+
def test_delta_has_apt_method(self):
577+
"""delta.json must have an apt available_method with package 'git-delta'."""
578+
with open(CATALOG_DIR / "delta.json") as f:
579+
data = json.load(f)
580+
methods = data.get("available_methods", [])
581+
apt_methods = [m for m in methods if m.get("method") == "apt"]
582+
assert len(apt_methods) == 1, (
583+
"delta.json should have exactly one apt available_method"
584+
)
585+
apt_config = apt_methods[0].get("config", {})
586+
assert apt_config.get("package") == "git-delta", (
587+
"delta.json apt config should have package 'git-delta'"
588+
)
589+
590+
def test_delta_has_available_methods(self):
591+
"""delta.json must have available_methods field."""
592+
with open(CATALOG_DIR / "delta.json") as f:
593+
data = json.load(f)
594+
assert "available_methods" in data, (
595+
"delta.json should have available_methods field"
596+
)
597+
# Should still have github_release_binary
598+
methods = data["available_methods"]
599+
method_names = [m.get("method") for m in methods]
600+
assert "github_release_binary" in method_names, (
601+
"delta.json should keep github_release_binary method"
602+
)
603+
604+
def test_bat_candidates_field_preserved_in_catalog_entry(self):
605+
"""ToolCatalogEntry.from_dict should parse candidates from bat.json."""
606+
from cli_audit.catalog import ToolCatalogEntry
607+
with open(CATALOG_DIR / "bat.json") as f:
608+
data = json.load(f)
609+
entry = ToolCatalogEntry.from_dict(data)
610+
assert entry.candidates is not None
611+
assert "batcat" in entry.candidates
612+
assert "bat" in entry.candidates
613+
614+
def test_fd_candidates_field_preserved_in_catalog_entry(self):
615+
"""ToolCatalogEntry.from_dict should parse candidates from fd.json."""
616+
from cli_audit.catalog import ToolCatalogEntry
617+
with open(CATALOG_DIR / "fd.json") as f:
618+
data = json.load(f)
619+
entry = ToolCatalogEntry.from_dict(data)
620+
assert entry.candidates is not None
621+
assert "fdfind" in entry.candidates
622+
assert "fd" in entry.candidates

tests/test_upgrade.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,128 @@ def test_bulk_upgrade_with_failures(
785785

786786
assert len(result.upgrades) == 1
787787
assert len(result.failures) == 1
788+
789+
790+
class TestAptPackageNameResolver:
791+
"""Tests for #35: apt package name resolution from catalog."""
792+
793+
def test_resolve_apt_package_name_fd(self):
794+
"""resolve_apt_package_name('fd') should return 'fd-find'."""
795+
from cli_audit.catalog import resolve_apt_package_name
796+
assert resolve_apt_package_name("fd") == "fd-find"
797+
798+
def test_resolve_apt_package_name_bat(self):
799+
"""resolve_apt_package_name('bat') should return 'bat'."""
800+
from cli_audit.catalog import resolve_apt_package_name
801+
assert resolve_apt_package_name("bat") == "bat"
802+
803+
def test_resolve_apt_package_name_delta(self):
804+
"""resolve_apt_package_name('delta') should return 'git-delta'."""
805+
from cli_audit.catalog import resolve_apt_package_name
806+
assert resolve_apt_package_name("delta") == "git-delta"
807+
808+
def test_resolve_apt_package_name_unknown_returns_tool_name(self):
809+
"""Tools without apt mapping should fall back to tool name."""
810+
from cli_audit.catalog import resolve_apt_package_name
811+
assert resolve_apt_package_name("nonexistent_tool_xyz") == "nonexistent_tool_xyz"
812+
813+
def test_resolve_apt_package_name_ripgrep(self):
814+
"""ripgrep has apt config with package 'ripgrep'."""
815+
from cli_audit.catalog import resolve_apt_package_name
816+
assert resolve_apt_package_name("ripgrep") == "ripgrep"
817+
818+
def test_resolve_apt_package_name_with_legacy_packages_field(self):
819+
"""Tools using legacy 'packages' field should resolve correctly."""
820+
import json
821+
from cli_audit.catalog import resolve_apt_package_name
822+
823+
fake_json = json.dumps({
824+
"name": "legacy_tool",
825+
"packages": {"apt": "legacy-apt-pkg"},
826+
})
827+
with patch("builtins.open", mock_open(read_data=fake_json)):
828+
with patch("pathlib.Path.exists", return_value=True):
829+
result = resolve_apt_package_name("legacy_tool")
830+
assert result == "legacy-apt-pkg"
831+
832+
def test_resolve_apt_package_name_tool_without_apt_method(self):
833+
"""Tools with available_methods but no apt method should fall back."""
834+
from cli_audit.catalog import resolve_apt_package_name
835+
# tokei has cargo method but no apt method
836+
result = resolve_apt_package_name("tokei")
837+
# Should fall back - check it doesn't crash at minimum
838+
assert isinstance(result, str)
839+
840+
def test_resolve_apt_package_name_with_malformed_json(self):
841+
"""Malformed catalog JSON should fall back to tool name."""
842+
from cli_audit.catalog import resolve_apt_package_name
843+
844+
with patch("builtins.open", mock_open(read_data="not valid json{{{")):
845+
with patch("pathlib.Path.exists", return_value=True):
846+
result = resolve_apt_package_name("broken_tool")
847+
assert result == "broken_tool"
848+
849+
850+
class TestCheckUpgradeAvailableAptResolved:
851+
"""Tests for #35: apt-cache policy uses resolved package name."""
852+
853+
@patch("cli_audit.upgrade.subprocess.run")
854+
def test_get_available_version_apt_uses_resolved_name(self, mock_run):
855+
"""get_available_version for apt must use resolve_apt_package_name."""
856+
clear_version_cache()
857+
858+
mock_run.return_value = MagicMock(
859+
returncode=0,
860+
stdout="fd-find:\n Installed: 8.7.0-1\n Candidate: 9.0.0-1\n",
861+
)
862+
863+
version = get_available_version("fd", "apt")
864+
865+
# Verify subprocess was called with the resolved package name
866+
mock_run.assert_called_once()
867+
call_args = mock_run.call_args[0][0]
868+
assert call_args == ["apt-cache", "policy", "fd-find"], (
869+
f"apt-cache policy should use resolved name 'fd-find', got: {call_args}"
870+
)
871+
assert version == "9.0.0"
872+
873+
clear_version_cache()
874+
875+
@patch("cli_audit.upgrade.subprocess.run")
876+
def test_get_available_version_apt_delta_uses_git_delta(self, mock_run):
877+
"""get_available_version for delta/apt must use 'git-delta'."""
878+
clear_version_cache()
879+
880+
mock_run.return_value = MagicMock(
881+
returncode=0,
882+
stdout="git-delta:\n Installed: (none)\n Candidate: 0.16.5-1\n",
883+
)
884+
885+
version = get_available_version("delta", "apt")
886+
887+
call_args = mock_run.call_args[0][0]
888+
assert call_args == ["apt-cache", "policy", "git-delta"], (
889+
f"apt-cache policy should use resolved name 'git-delta', got: {call_args}"
890+
)
891+
assert version == "0.16.5"
892+
893+
clear_version_cache()
894+
895+
@patch("cli_audit.upgrade.subprocess.run")
896+
def test_get_available_version_apt_ripgrep_unchanged(self, mock_run):
897+
"""get_available_version for ripgrep/apt should still use 'ripgrep'."""
898+
clear_version_cache()
899+
900+
mock_run.return_value = MagicMock(
901+
returncode=0,
902+
stdout="ripgrep:\n Installed: 14.1.0-1\n Candidate: 14.1.1-1\n",
903+
)
904+
905+
version = get_available_version("ripgrep", "apt")
906+
907+
call_args = mock_run.call_args[0][0]
908+
assert call_args == ["apt-cache", "policy", "ripgrep"], (
909+
f"apt-cache policy should use 'ripgrep', got: {call_args}"
910+
)
911+
912+
clear_version_cache()

0 commit comments

Comments
 (0)