Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions catalog/bat.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"homepage": "https://github.com/sharkdp/bat",
"github_repo": "sharkdp/bat",
"binary_name": "bat",
"candidates": ["bat", "batcat"],
"download_url_template": "https://github.com/sharkdp/bat/releases/download/{version}/bat-{version}-{arch}-unknown-linux-musl.tar.gz",
"arch_map": {
"x86_64": "x86_64",
Expand Down
26 changes: 25 additions & 1 deletion catalog/delta.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "delta",
"category": "git",
"install_method": "github_release_binary",
"install_method": "auto",
"description": "A syntax-highlighting pager for git, diff, and grep output",
"homepage": "https://github.com/dandavison/delta",
"github_repo": "dandavison/delta",
Expand All @@ -12,6 +12,30 @@
"aarch64": "aarch64",
"armv7l": "armv7"
},
"available_methods": [
{
"method": "github_release_binary",
"priority": 1,
"config": {
"repo": "dandavison/delta",
"asset_pattern": "delta-.*-x86_64-unknown-linux-musl.tar.gz"
}
},
{
"method": "cargo",
"priority": 2,
"config": {
"crate": "git-delta"
}
},
{
"method": "apt",
"priority": 3,
"config": {
"package": "git-delta"
}
}
],
"tags": [
"core"
]
Expand Down
1 change: 1 addition & 0 deletions catalog/fd.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"homepage": "https://github.com/sharkdp/fd",
"github_repo": "sharkdp/fd",
"binary_name": "fd",
"candidates": ["fd", "fdfind"],
"download_url_template": "https://github.com/sharkdp/fd/releases/download/{version}/fd-{version}-{arch}-unknown-linux-musl.tar.gz",
"arch_map": {
"x86_64": "x86_64",
Expand Down
35 changes: 35 additions & 0 deletions cli_audit/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,41 @@ def get_package_manager_tools(self) -> list[ToolCatalogEntry]:
]


def resolve_apt_package_name(tool_name: str) -> str:
"""Resolve the apt package name for a tool from its catalog entry.

Reads available_methods from the catalog JSON and returns the apt
package name if configured, otherwise falls back to tool_name.

Args:
tool_name: Tool name to resolve

Returns:
The apt package name from the catalog, or tool_name as fallback
"""
catalog_path = Path(__file__).parent.parent / "catalog" / f"{tool_name}.json"
if not catalog_path.exists():
return tool_name
try:
Comment thread
CybotTM marked this conversation as resolved.
with open(catalog_path) as f:
data = json.load(f)
# Check available_methods for apt entry (modern format)
for method in data.get("available_methods", []):
if method.get("method") == "apt":
return method.get("config", {}).get("package", tool_name)
# Check legacy package_managers field
pkg_mgrs = data.get("package_managers", {})
if "apt" in pkg_mgrs:
return pkg_mgrs["apt"]
# Check legacy packages field
packages = data.get("packages", {})
if "apt" in packages:
return packages["apt"]
except json.JSONDecodeError:
pass
return tool_name


def detect_package_manager() -> tuple[str, str] | None:
"""Detect the current OS package manager and upgrade command.

Expand Down
4 changes: 3 additions & 1 deletion cli_audit/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,10 @@ def get_available_version(
return version_str

elif package_manager == "apt":
from .catalog import resolve_apt_package_name
apt_pkg = resolve_apt_package_name(tool_name)
Comment thread
CybotTM marked this conversation as resolved.
result = subprocess.run(
["apt-cache", "policy", tool_name],
["apt-cache", "policy", apt_pkg],
capture_output=True,
text=True,
timeout=10,
Expand Down
80 changes: 80 additions & 0 deletions tests/test_update_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,83 @@ def test_catalog_install_method_has_installer(self, catalog_name):
assert installer.exists(), (
f"{catalog_name}: installer {installer} not found for install_method={method}"
)


# ===========================================================================
# 12. Debian/Ubuntu package naming mismatches (#35)
# ===========================================================================

class TestCatalogDebianNaming:
"""Tests for #35: Debian/Ubuntu package naming mismatches."""

def test_bat_has_batcat_candidate(self):
"""bat.json must include 'batcat' in candidates for Debian detection."""
with open(CATALOG_DIR / "bat.json") as f:
data = json.load(f)
candidates = data.get("candidates", [])
assert "batcat" in candidates, (
"bat.json should have 'batcat' in candidates for Debian/Ubuntu"
)
assert "bat" in candidates, (
"bat.json should also keep 'bat' in candidates"
)

def test_fd_has_fdfind_candidate(self):
"""fd.json must include 'fdfind' in candidates for Debian detection."""
with open(CATALOG_DIR / "fd.json") as f:
data = json.load(f)
candidates = data.get("candidates", [])
assert "fdfind" in candidates, (
"fd.json should have 'fdfind' in candidates for Debian/Ubuntu"
)
assert "fd" in candidates, (
"fd.json should also keep 'fd' in candidates"
)

def test_delta_has_apt_method(self):
"""delta.json must have an apt available_method with package 'git-delta'."""
with open(CATALOG_DIR / "delta.json") as f:
data = json.load(f)
methods = data.get("available_methods", [])
apt_methods = [m for m in methods if m.get("method") == "apt"]
assert len(apt_methods) == 1, (
"delta.json should have exactly one apt available_method"
)
apt_config = apt_methods[0].get("config", {})
assert apt_config.get("package") == "git-delta", (
"delta.json apt config should have package 'git-delta'"
)

def test_delta_has_available_methods(self):
"""delta.json must have available_methods field."""
with open(CATALOG_DIR / "delta.json") as f:
data = json.load(f)
assert "available_methods" in data, (
"delta.json should have available_methods field"
)
# Should still have github_release_binary
methods = data["available_methods"]
method_names = [m.get("method") for m in methods]
assert "github_release_binary" in method_names, (
"delta.json should keep github_release_binary method"
)

def test_bat_candidates_field_preserved_in_catalog_entry(self):
"""ToolCatalogEntry.from_dict should parse candidates from bat.json."""
from cli_audit.catalog import ToolCatalogEntry
with open(CATALOG_DIR / "bat.json") as f:
data = json.load(f)
entry = ToolCatalogEntry.from_dict(data)
assert entry.candidates is not None
assert "batcat" in entry.candidates
assert "bat" in entry.candidates

def test_fd_candidates_field_preserved_in_catalog_entry(self):
"""ToolCatalogEntry.from_dict should parse candidates from fd.json."""
from cli_audit.catalog import ToolCatalogEntry
with open(CATALOG_DIR / "fd.json") as f:
data = json.load(f)
entry = ToolCatalogEntry.from_dict(data)
assert entry.candidates is not None
assert "fdfind" in entry.candidates
assert "fd" in entry.candidates
125 changes: 125 additions & 0 deletions tests/test_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,128 @@ def test_bulk_upgrade_with_failures(

assert len(result.upgrades) == 1
assert len(result.failures) == 1


class TestAptPackageNameResolver:
"""Tests for #35: apt package name resolution from catalog."""

def test_resolve_apt_package_name_fd(self):
"""resolve_apt_package_name('fd') should return 'fd-find'."""
from cli_audit.catalog import resolve_apt_package_name
assert resolve_apt_package_name("fd") == "fd-find"

def test_resolve_apt_package_name_bat(self):
"""resolve_apt_package_name('bat') should return 'bat'."""
from cli_audit.catalog import resolve_apt_package_name
assert resolve_apt_package_name("bat") == "bat"

def test_resolve_apt_package_name_delta(self):
"""resolve_apt_package_name('delta') should return 'git-delta'."""
from cli_audit.catalog import resolve_apt_package_name
assert resolve_apt_package_name("delta") == "git-delta"

def test_resolve_apt_package_name_unknown_returns_tool_name(self):
"""Tools without apt mapping should fall back to tool name."""
from cli_audit.catalog import resolve_apt_package_name
assert resolve_apt_package_name("nonexistent_tool_xyz") == "nonexistent_tool_xyz"

def test_resolve_apt_package_name_ripgrep(self):
"""ripgrep has apt config with package 'ripgrep'."""
from cli_audit.catalog import resolve_apt_package_name
assert resolve_apt_package_name("ripgrep") == "ripgrep"

def test_resolve_apt_package_name_with_legacy_packages_field(self):
"""Tools using legacy 'packages' field should resolve correctly."""
import json
from cli_audit.catalog import resolve_apt_package_name

fake_json = json.dumps({
"name": "legacy_tool",
"packages": {"apt": "legacy-apt-pkg"},
})
with patch("builtins.open", mock_open(read_data=fake_json)):
with patch("pathlib.Path.exists", return_value=True):
result = resolve_apt_package_name("legacy_tool")
assert result == "legacy-apt-pkg"

def test_resolve_apt_package_name_tool_without_apt_method(self):
"""Tools with available_methods but no apt method should fall back."""
from cli_audit.catalog import resolve_apt_package_name
# tokei has cargo method but no apt method
result = resolve_apt_package_name("tokei")
# Should fall back - check it doesn't crash at minimum
assert isinstance(result, str)

def test_resolve_apt_package_name_with_malformed_json(self):
"""Malformed catalog JSON should fall back to tool name."""
from cli_audit.catalog import resolve_apt_package_name

with patch("builtins.open", mock_open(read_data="not valid json{{{")):
with patch("pathlib.Path.exists", return_value=True):
result = resolve_apt_package_name("broken_tool")
assert result == "broken_tool"


class TestCheckUpgradeAvailableAptResolved:
"""Tests for #35: apt-cache policy uses resolved package name."""

@patch("cli_audit.upgrade.subprocess.run")
def test_get_available_version_apt_uses_resolved_name(self, mock_run):
"""get_available_version for apt must use resolve_apt_package_name."""
clear_version_cache()

mock_run.return_value = MagicMock(
returncode=0,
stdout="fd-find:\n Installed: 8.7.0-1\n Candidate: 9.0.0-1\n",
)

version = get_available_version("fd", "apt")

# Verify subprocess was called with the resolved package name
mock_run.assert_called_once()
call_args = mock_run.call_args[0][0]
assert call_args == ["apt-cache", "policy", "fd-find"], (
f"apt-cache policy should use resolved name 'fd-find', got: {call_args}"
)
assert version == "9.0.0"

clear_version_cache()

@patch("cli_audit.upgrade.subprocess.run")
def test_get_available_version_apt_delta_uses_git_delta(self, mock_run):
"""get_available_version for delta/apt must use 'git-delta'."""
clear_version_cache()

mock_run.return_value = MagicMock(
returncode=0,
stdout="git-delta:\n Installed: (none)\n Candidate: 0.16.5-1\n",
)

version = get_available_version("delta", "apt")

call_args = mock_run.call_args[0][0]
assert call_args == ["apt-cache", "policy", "git-delta"], (
f"apt-cache policy should use resolved name 'git-delta', got: {call_args}"
)
assert version == "0.16.5"

clear_version_cache()

@patch("cli_audit.upgrade.subprocess.run")
def test_get_available_version_apt_ripgrep_unchanged(self, mock_run):
"""get_available_version for ripgrep/apt should still use 'ripgrep'."""
clear_version_cache()

mock_run.return_value = MagicMock(
returncode=0,
stdout="ripgrep:\n Installed: 14.1.0-1\n Candidate: 14.1.1-1\n",
)

version = get_available_version("ripgrep", "apt")

call_args = mock_run.call_args[0][0]
assert call_args == ["apt-cache", "policy", "ripgrep"], (
f"apt-cache policy should use 'ripgrep', got: {call_args}"
)

clear_version_cache()
Loading