From f5580479f8ea03f8c2fd8f0819316d800a5a10dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Fri, 20 Feb 2026 22:24:13 +0100 Subject: [PATCH] feat: add support for nix package manager This commit adds support for managing packages using the nix package manager. --- changelog/68752.added.md | 1 + doc/ref/modules/all/index.rst | 1 + doc/ref/modules/all/salt.modules.nixpkg.rst | 6 + doc/ref/modules/all/salt.modules.pkg.rst | 3 + salt/modules/nixpkg.py | 522 ++++++++++++++++++++ tests/pytests/unit/modules/test_nixpkg.py | 393 +++++++++++++++ 6 files changed, 926 insertions(+) create mode 100644 changelog/68752.added.md create mode 100644 doc/ref/modules/all/salt.modules.nixpkg.rst create mode 100644 salt/modules/nixpkg.py create mode 100644 tests/pytests/unit/modules/test_nixpkg.py diff --git a/changelog/68752.added.md b/changelog/68752.added.md new file mode 100644 index 000000000000..8afe7dc9d47e --- /dev/null +++ b/changelog/68752.added.md @@ -0,0 +1 @@ +Add support for nix package manager. diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index 685ff13d1158..de751f76a1f3 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -147,6 +147,7 @@ execution modules network nfs3 nftables + nixpkg npm nxos nxos_api diff --git a/doc/ref/modules/all/salt.modules.nixpkg.rst b/doc/ref/modules/all/salt.modules.nixpkg.rst new file mode 100644 index 000000000000..903501d1e468 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.nixpkg.rst @@ -0,0 +1,6 @@ +salt.modules.nixpkg +=================== + +.. automodule:: salt.modules.nixpkg + :members: + :exclude-members: available_version diff --git a/doc/ref/modules/all/salt.modules.pkg.rst b/doc/ref/modules/all/salt.modules.pkg.rst index c5972c18411f..ef2600788f26 100644 --- a/doc/ref/modules/all/salt.modules.pkg.rst +++ b/doc/ref/modules/all/salt.modules.pkg.rst @@ -22,6 +22,8 @@ Execution Module Used for ``emerge(1)``) :py:mod:`~salt.modules.freebsdpkg` FreeBSD-based OSes using ``pkg_add(1)`` :py:mod:`~salt.modules.openbsdpkg` OpenBSD-based OSes using ``pkg_add(1)`` +:py:mod:`~salt.modules.nixpkg` Systems using the `Nix`_ package + manager :py:mod:`~salt.modules.pacmanpkg` Arch Linux-based distros using ``pacman(8)`` :py:mod:`~salt.modules.pkgin` NetBSD-based OSes using ``pkgin(1)`` @@ -38,4 +40,5 @@ Execution Module Used for ====================================== ======================================== .. _Homebrew: https://brew.sh/ +.. _Nix: https://nixos.org/ .. _OpenCSW: https://www.opencsw.org/ diff --git a/salt/modules/nixpkg.py b/salt/modules/nixpkg.py new file mode 100644 index 000000000000..3b7a7d12e4d7 --- /dev/null +++ b/salt/modules/nixpkg.py @@ -0,0 +1,522 @@ +""" +Work with Nix packages +====================== + +.. versionadded:: 3007.14 + +Does not require the machine to be Nixos, just have Nix installed and available +to use for the user running this command. Their profile must be located in +their home, under ``$HOME/.nix-profile/``, and the nix store, unless specially +set up, should be in ``/nix``. To easily use this with multiple users or a root +user, set up the `nix-daemon`_. + +This module is compatible with the ``pkg`` state, so you can use it with +``pkg.installed``, ``pkg.removed``, ``pkg.latest``, etc. + +For more information on nix, see the `nix documentation`_. + +.. _`nix documentation`: https://nix.dev/manual/nix/latest/ +.. _`nix-daemon`: https://nix.dev/manual/nix/latest/installation/multi-user +""" + +import copy +import logging +import os +import re + +import salt.utils.data +import salt.utils.functools +import salt.utils.json +import salt.utils.path +from salt.exceptions import CommandExecutionError, MinionError + +logger = logging.getLogger(__name__) + +__virtualname__ = "pkg" + + +def __virtual__(): + """ + This only works if we have access to nix + """ + nixhome = _nix_home() + if salt.utils.path.which(os.path.join(nixhome, "nix")) and salt.utils.path.which( + os.path.join(nixhome, "nix-collect-garbage") + ): + return __virtualname__ + else: + return ( + False, + "The `nix` binaries required cannot be found or are not installed." + " (`nix-collect-garbage` and `nix`)", + ) + + +def _nix_user(): + """ + Get the user that nix is running as. + This is the user defined in `nixpkg.user`, `nix.user` or the user that is running the salt command. + """ + return __opts__.get("nixpkg.user", __opts__.get("nix.user", __opts__["user"])) + + +def _nix_home(): + """ + Get the path to the nix profile for the nix user. + """ + return os.path.join(os.path.expanduser(f"~{_nix_user()}"), ".nix-profile/bin/") + + +def _run(cmd): + """ + Just a convenience function for ``__salt__['cmd.run_all'](cmd)`` + """ + return __salt__["cmd.run_all"](cmd, runas=_nix_user()) + + +def _nix_profile(): + """ + nix profile command + """ + return [os.path.join(_nix_home(), "nix"), "profile"] + + +def _nix_collect_garbage(): + """ + Make sure we get the right nix-collect-garbage, too. + """ + return [os.path.join(_nix_home(), "nix-collect-garbage")] + + +def upgrade(*pkgs, **kwargs): + """ + Runs an update operation on the specified packages, or all packages if none is specified. + + :type pkgs: list(str) + :param pkgs: + List of packages to update + + :return: The upgraded packages. Example element: ``['libxslt-1.1.0', 'libxslt-1.1.10']`` + :rtype: list(tuple(str, str)) + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.upgrade + salt '*' pkg.upgrade pkgs=one,two + """ + if salt.utils.data.is_true(kwargs.get("refresh", True)): + refresh_db() + + logger.info("Upgrading packages: %s", pkgs) + + old = list_pkgs() + logger.debug("Initial packages: %s", old) + + cmd = _nix_profile() + cmd.append("upgrade") + if pkgs: + cmd.extend(pkgs) + else: + cmd.append("--all") + + out = _run(cmd) + if out["retcode"] != 0 and out["stderr"]: + errors = [out["stderr"]] + else: + errors = [] + + __context__.pop("pkg.list_pkgs", None) + new = list_pkgs() + logger.debug("Final packages: %s", new) + + ret = salt.utils.data.compare_dicts(old, new) + logger.debug("Package changes: %s", ret) + + if errors: + raise CommandExecutionError( + "Problem encountered upgrading package(s)", + info={"errors": errors, "changes": ret}, + ) + + return ret + + +def list_upgrades(refresh=True, **kwargs): + """ + List all available package upgrades. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.list_upgrades + """ + if salt.utils.data.is_true(refresh): + refresh_db() + + old = list_pkgs() + ret = {} + for pkg_name in old: + latest = latest_version(pkg_name, refresh=False) + if latest: + ret[pkg_name] = latest + return ret + + +def _add_source(pkg): + return f"nixpkgs#{pkg}" if "#" not in pkg else pkg + + +def install(name=None, pkgs=None, **kwargs): + """ + Installs a single or multiple packages via nix profile + + :type name: str + :param name: + package to install + :type pkgs: list(str) + :param pkgs: + packages to install + + :return: Installed packages. Example element: ``gcc-3.3.2`` + :rtype: list(str) + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.install vim + salt '*' pkg.install pkgs='[vim, git]' + """ + + try: + targets, pkg_type = __salt__["pkg_resource.parse_targets"]( + name, pkgs, kwargs.get("sources", {}) + ) + except MinionError as exc: + raise CommandExecutionError(exc) + + if not targets: + return {} + + logger.info("Installing packages: %s", targets) + + old = list_pkgs() + logger.debug("Initial packages: %s", old) + + cmd = _nix_profile() + cmd.append("add") + cmd.extend(list(map(_add_source, targets))) + + out = _run(cmd) + if out["retcode"] != 0 and out["stderr"]: + errors = [out["stderr"]] + else: + errors = [] + + __context__.pop("pkg.list_pkgs", None) + new = list_pkgs() + logger.debug("Final packages: %s", new) + + ret = salt.utils.data.compare_dicts(old, new) + logger.debug("Package changes: %s", ret) + + if errors: + raise CommandExecutionError( + "Problem encountered installing package(s)", + info={"errors": errors, "changes": ret}, + ) + + return ret + + +def _list_pkgs_from_context(versions_as_list): + """ + Use pkg list from __context__ + """ + if versions_as_list: + return __context__["pkg.list_pkgs"] + + ret = copy.deepcopy(__context__["pkg.list_pkgs"]) + __salt__["pkg_resource.stringify"](ret) + return ret + + +def _extract_version(info): + # Extract the version from a Nix store path. + # Store paths have the format: /nix/store/--[-] + # The hash is base32 (no hyphens), so the first hyphen after the basename + # separates the hash from -. + # We find the version by looking for the first segment that starts with a digit. + for store_path in info.get("storePaths", []): + basename = store_path.rsplit("/", 1)[-1] + # Split off the hash (first segment before the first hyphen) + _, _, rest = basename.partition("-") + if not rest: + continue + # Find the version: first hyphen-separated part that starts with a digit + parts = rest.split("-") + for part in parts: + if part and re.match(r"\d", part): + return part + return "unknown" + + +def list_pkgs(versions_as_list=False, **kwargs): + """ + Lists installed packages. + + :param bool versions_as_list: + returns versions as lists, not strings. + Default: False + + :return: Packages installed or available, along with their attributes. + :rtype: list(list(str)) + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.list_pkgs + salt '*' pkg.list_pkgs versions_as_list=True + """ + + if kwargs.get("purge_desired", False): + return {} + + versions_as_list = salt.utils.data.is_true(versions_as_list) + + if "pkg.list_pkgs" in __context__ and kwargs.get("use_context", True): + return _list_pkgs_from_context(versions_as_list) + + cmd = _nix_profile() + cmd.append("list") + cmd.append("--json") + + package_info = salt.utils.json.loads(_run(cmd)["stdout"]) + package_info = package_info.get("elements", {}) + + ret = {} + for pkg_name, info in package_info.items(): + version = _extract_version(info) + __salt__["pkg_resource.add_pkg"](ret, pkg_name, version) + + __salt__["pkg_resource.sort_pkglist"](ret) + __context__["pkg.list_pkgs"] = copy.deepcopy(ret) + if not versions_as_list: + __salt__["pkg_resource.stringify"](ret) + + return ret + + +def version(*names, **kwargs): + """ + Returns a string representing the package version or an empty string if not + installed. If more than one package name is specified, a dict of + name/version pairs is returned. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.version + salt '*' pkg.version ... + """ + return __salt__["pkg_resource.version"](*names, **kwargs) + + +def latest_version(*names, **kwargs): + """ + Return the latest version of the named package available for upgrade or + installation. + + Since Nix doesn't have a simple way to query the latest available version + without performing a search, this queries ``nix search`` for each package. + + If the latest version of a given package is already installed, an empty + string will be returned for that package. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.latest_version + salt '*' pkg.latest_version ... + """ + refresh = salt.utils.data.is_true(kwargs.get("refresh", True)) + if refresh: + refresh_db() + + installed = list_pkgs() + ret = {} + + for name in names: + cmd = [os.path.join(_nix_home(), "nix"), "search", "nixpkgs", name, "--json"] + out = _run(cmd) + if out["retcode"] != 0: + ret[name] = "" + continue + + try: + search_results = salt.utils.json.loads(out["stdout"]) + except ValueError: + ret[name] = "" + continue + + # Search results are keyed by attribute path, e.g. "legacyPackages.x86_64-linux.vim" + # Find the best match for the package name + best_version = "" + for attr_path, info in search_results.items(): + pkg_attr = attr_path.rsplit(".", 1)[-1] if "." in attr_path else attr_path + if pkg_attr == name: + best_version = info.get("version", "") + break + else: + # If no exact match, take the first result + for info in search_results.values(): + best_version = info.get("version", "") + break + + # If the installed version matches the latest, return empty string + if name in installed and installed[name] == best_version: + ret[name] = "" + else: + ret[name] = best_version + + if len(names) == 1: + return ret.get(names[0], "") + return ret + + +# available_version is being deprecated +available_version = salt.utils.functools.alias_function( + latest_version, "available_version" +) + + +def refresh_db(**kwargs): + """ + Nix doesn't have a traditional package database to refresh, + but this updates the flake registry / channel. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.refresh_db + """ + cmd = [os.path.join(_nix_home(), "nix"), "flake", "prefetch", "nixpkgs"] + out = _run(cmd) + return out["retcode"] == 0 + + +def uninstall(*pkgs): + """ + Erases a package from the current nix profile. + Nix uninstalls work differently than other package managers, and the symlinks in the profile are removed, + while the actual package remains. + There is also a ``pkg.purge`` function, to clear the package cache of unused packages. + + :type pkgs: list(str) + :param pkgs: + List, single package to uninstall + + :return: Packages that have been uninstalled + :rtype: list(str) + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.uninstall vim + salt '*' pkg.uninstall vim git + """ + + return remove(pkgs=pkgs) + + +def remove(name=None, pkgs=None, **kwargs): + """ + Removes packages with ``nix profile remove``. + + :param str name: + Package to remove + + :param list pkgs: + List of packages to remove + + :return: A dict containing the changes + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.remove vim + salt '*' pkg.remove pkgs='[vim, git]' + """ + try: + pkg_params = __salt__["pkg_resource.parse_targets"](name, pkgs, **kwargs)[0] + except MinionError as exc: + raise CommandExecutionError(exc) + + old = list_pkgs() + logger.debug("Initial packages: %s", old) + + targets = [x for x in pkg_params if x in old] + if not targets: + return {} + + logger.info("Removing packages: %s", targets) + + cmd = _nix_profile() + cmd.append("remove") + cmd.extend(list(targets)) + + out = _run(cmd) + if out["retcode"] != 0 and out["stderr"]: + errors = [out["stderr"]] + else: + errors = [] + + __context__.pop("pkg.list_pkgs", None) + new = list_pkgs() + logger.debug("Final packages: %s", new) + + ret = salt.utils.data.compare_dicts(old, new) + logger.debug("Package changes: %s", ret) + + if errors: + raise CommandExecutionError( + "Problem encountered removing package(s)", + info={"errors": errors, "changes": ret}, + ) + + return ret + + +def collect_garbage(): + """ + Completely removed all currently 'uninstalled' packages in the nix store. + + Tells the user how many store paths were removed and how much space was freed. + + :return: How much space was freed and how many derivations were removed + :rtype: str + + .. warning:: + This is a destructive action on the nix store. + + CLI Example: + + .. code-block:: bash + + salt '*' pkg.collect_garbage + """ + cmd = _nix_collect_garbage() + cmd.append("--delete-old") + + out = _run(cmd) + + return out["stdout"].splitlines() diff --git a/tests/pytests/unit/modules/test_nixpkg.py b/tests/pytests/unit/modules/test_nixpkg.py new file mode 100644 index 000000000000..fc895d95d1da --- /dev/null +++ b/tests/pytests/unit/modules/test_nixpkg.py @@ -0,0 +1,393 @@ +""" +Unit tests for the nixpkg execution module. +""" + +import copy +import json + +import pytest + +import salt.modules.nixpkg as nixpkg +from salt.exceptions import CommandExecutionError +from tests.support.mock import MagicMock, patch + +NIX_LIST_JSON = json.dumps( + { + "elements": { + "vim": {"storePaths": ["/nix/store/abc123-vim-9.0.1"]}, + "git": {"storePaths": ["/nix/store/def456-git-2.42.0"]}, + } + } +) + +NIX_SEARCH_VIM_JSON = json.dumps( + { + "legacyPackages.x86_64-linux.vim": { + "pname": "vim", + "version": "9.1.0", + "description": "The most popular clone of the VI editor", + } + } +) + + +@pytest.fixture +def configure_loader_modules(): + return { + nixpkg: { + "__opts__": {"user": "testuser"}, + "__context__": {}, + } + } + + +# __virtual__ tests + + +def test_virtual_success(): + with patch( + "salt.utils.path.which", return_value="/home/testuser/.nix-profile/bin/nix" + ): + result = nixpkg.__virtual__() + assert result == "pkg" + + +def test_virtual_missing_binaries(): + with patch("salt.utils.path.which", return_value=None): + result = nixpkg.__virtual__() + assert isinstance(result, tuple) + assert result[0] is False + assert "nix" in result[1] + + +# _extract_version tests + + +def test_extract_version_standard_path(): + info = {"storePaths": ["/nix/store/4s0nkdxk97ckjs90ag0arsxli912pymy-aria2-1.37.0"]} + assert nixpkg._extract_version(info) == "1.37.0" + + +def test_extract_version_with_suffix(): + info = { + "storePaths": ["/nix/store/4s0nkdxk97ckjs90ag0arsxli912pymy-aria2-1.37.0-bin"] + } + assert nixpkg._extract_version(info) == "1.37.0" + + +def test_extract_version_hyphenated_name(): + info = {"storePaths": ["/nix/store/abc123-google-chrome-120.0.1"]} + assert nixpkg._extract_version(info) == "120.0.1" + + +def test_extract_version_hyphenated_name_with_output(): + info = {"storePaths": ["/nix/store/abc123-xorg-server-21.1.8-dev"]} + assert nixpkg._extract_version(info) == "21.1.8" + + +def test_extract_version_no_store_paths(): + info = {} + assert nixpkg._extract_version(info) == "unknown" + + +def test_extract_version_empty_store_paths(): + info = {"storePaths": []} + assert nixpkg._extract_version(info) == "unknown" + + +# _add_source tests + + +def test_add_source_plain_package(): + assert nixpkg._add_source("vim") == "nixpkgs#vim" + + +def test_add_source_already_qualified(): + assert nixpkg._add_source("nixpkgs#vim") == "nixpkgs#vim" + + +def test_add_source_custom_flake(): + assert nixpkg._add_source("myflake#vim") == "myflake#vim" + + +# list_pkgs tests + + +def test_list_pkgs(): + mock_run = MagicMock(return_value={"stdout": NIX_LIST_JSON, "retcode": 0}) + mock_add_pkg = MagicMock( + side_effect=lambda ret, name, ver: ret.update({name: [ver]}) + ) + mock_sort = MagicMock() + mock_stringify = MagicMock( + side_effect=lambda ret: ret.update({k: ",".join(v) for k, v in ret.items()}) + ) + + with patch.dict( + nixpkg.__salt__, + { + "cmd.run_all": mock_run, + "pkg_resource.add_pkg": mock_add_pkg, + "pkg_resource.sort_pkglist": mock_sort, + "pkg_resource.stringify": mock_stringify, + }, + ): + result = nixpkg.list_pkgs() + assert "vim" in result + assert "git" in result + + +def test_list_pkgs_purge_desired(): + assert nixpkg.list_pkgs(purge_desired=True) == {} + + +def test_list_pkgs_from_context(): + mock_context = {"vim": ["9.0.1"], "git": ["2.42.0"]} + mock_stringify = MagicMock() + + with patch.dict(nixpkg.__context__, {"pkg.list_pkgs": mock_context}): + with patch.dict(nixpkg.__salt__, {"pkg_resource.stringify": mock_stringify}): + result = nixpkg.list_pkgs(versions_as_list=True) + assert result == mock_context + + +def test_list_pkgs_from_context_stringified(): + mock_context = {"vim": ["9.0.1"], "git": ["2.42.0"]} + + def mock_stringify(ret): + for k, v in ret.items(): + ret[k] = ",".join(v) + + with patch.dict(nixpkg.__context__, {"pkg.list_pkgs": mock_context}): + with patch.dict(nixpkg.__salt__, {"pkg_resource.stringify": mock_stringify}): + result = nixpkg.list_pkgs(versions_as_list=False) + assert result["vim"] == "9.0.1" + assert result["git"] == "2.42.0" + + +# install tests + + +def test_install_single_package(): + mock_parse = MagicMock(return_value=({"vim": None}, "repository")) + mock_run = MagicMock(return_value={"stdout": "", "stderr": "", "retcode": 0}) + + old_pkgs = {"git": "2.42.0"} + new_pkgs = {"git": "2.42.0", "vim": "9.0.1"} + call_count = {"n": 0} + + def mock_list_pkgs(**kwargs): + call_count["n"] += 1 + return copy.deepcopy(old_pkgs if call_count["n"] == 1 else new_pkgs) + + with patch.dict( + nixpkg.__salt__, + { + "pkg_resource.parse_targets": mock_parse, + "cmd.run_all": mock_run, + }, + ): + with patch.object(nixpkg, "list_pkgs", side_effect=mock_list_pkgs): + result = nixpkg.install(name="vim") + assert "vim" in result + assert result["vim"]["new"] == "9.0.1" + assert result["vim"]["old"] == "" + + +def test_install_no_targets(): + mock_parse = MagicMock(return_value=({}, "repository")) + + with patch.dict(nixpkg.__salt__, {"pkg_resource.parse_targets": mock_parse}): + result = nixpkg.install(name=None) + assert result == {} + + +def test_install_error(): + mock_parse = MagicMock(return_value=({"vim": None}, "repository")) + mock_run = MagicMock( + return_value={"stdout": "", "stderr": "error: package not found", "retcode": 1} + ) + + with patch.dict( + nixpkg.__salt__, + { + "pkg_resource.parse_targets": mock_parse, + "cmd.run_all": mock_run, + }, + ): + with patch.object(nixpkg, "list_pkgs", return_value={}): + with pytest.raises(CommandExecutionError): + nixpkg.install(name="nonexistent") + + +# remove tests + + +def test_remove_single_package(): + mock_parse = MagicMock(return_value=({"vim": None}, "repository")) + mock_run = MagicMock(return_value={"stdout": "", "stderr": "", "retcode": 0}) + + old_pkgs = {"git": "2.42.0", "vim": "9.0.1"} + new_pkgs = {"git": "2.42.0"} + call_count = {"n": 0} + + def mock_list_pkgs(**kwargs): + call_count["n"] += 1 + return copy.deepcopy(old_pkgs if call_count["n"] == 1 else new_pkgs) + + with patch.dict( + nixpkg.__salt__, + { + "pkg_resource.parse_targets": mock_parse, + "cmd.run_all": mock_run, + }, + ): + with patch.object(nixpkg, "list_pkgs", side_effect=mock_list_pkgs): + result = nixpkg.remove(name="vim") + assert "vim" in result + assert result["vim"]["old"] == "9.0.1" + assert result["vim"]["new"] == "" + + +def test_remove_package_not_installed(): + mock_parse = MagicMock(return_value=({"vim": None}, "repository")) + + with patch.dict(nixpkg.__salt__, {"pkg_resource.parse_targets": mock_parse}): + with patch.object(nixpkg, "list_pkgs", return_value={"git": "2.42.0"}): + result = nixpkg.remove(name="vim") + assert result == {} + + +# upgrade tests + + +def test_upgrade_all(): + mock_run = MagicMock(return_value={"stdout": "", "stderr": "", "retcode": 0}) + + old_pkgs = {"vim": "9.0.1"} + new_pkgs = {"vim": "9.1.0"} + call_count = {"n": 0} + + def mock_list_pkgs(**kwargs): + call_count["n"] += 1 + return copy.deepcopy(old_pkgs if call_count["n"] == 1 else new_pkgs) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + with patch.object(nixpkg, "list_pkgs", side_effect=mock_list_pkgs): + with patch.object(nixpkg, "refresh_db", return_value=True): + result = nixpkg.upgrade() + assert "vim" in result + assert result["vim"]["old"] == "9.0.1" + assert result["vim"]["new"] == "9.1.0" + + +def test_upgrade_error(): + mock_run = MagicMock( + return_value={"stdout": "", "stderr": "error: upgrade failed", "retcode": 1} + ) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + with patch.object(nixpkg, "list_pkgs", return_value={"vim": "9.0.1"}): + with patch.object(nixpkg, "refresh_db", return_value=True): + with pytest.raises(CommandExecutionError): + nixpkg.upgrade() + + +# version tests + + +def test_version(): + mock_version = MagicMock(return_value="9.0.1") + with patch.dict(nixpkg.__salt__, {"pkg_resource.version": mock_version}): + result = nixpkg.version("vim") + assert result == "9.0.1" + mock_version.assert_called_once_with("vim") + + +# latest_version tests + + +def test_latest_version_single(): + mock_run = MagicMock(return_value={"stdout": NIX_SEARCH_VIM_JSON, "retcode": 0}) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + with patch.object(nixpkg, "list_pkgs", return_value={"vim": "9.0.1"}): + with patch.object(nixpkg, "refresh_db", return_value=True): + result = nixpkg.latest_version("vim") + assert result == "9.1.0" + + +def test_latest_version_already_latest(): + search_json = json.dumps( + { + "legacyPackages.x86_64-linux.vim": { + "pname": "vim", + "version": "9.0.1", + } + } + ) + mock_run = MagicMock(return_value={"stdout": search_json, "retcode": 0}) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + with patch.object(nixpkg, "list_pkgs", return_value={"vim": "9.0.1"}): + with patch.object(nixpkg, "refresh_db", return_value=True): + result = nixpkg.latest_version("vim") + assert result == "" + + +def test_latest_version_not_found(): + mock_run = MagicMock( + return_value={"stdout": "", "stderr": "not found", "retcode": 1} + ) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + with patch.object(nixpkg, "list_pkgs", return_value={}): + with patch.object(nixpkg, "refresh_db", return_value=True): + result = nixpkg.latest_version("nonexistent") + assert result == "" + + +# refresh_db tests + + +def test_refresh_db_success(): + mock_run = MagicMock(return_value={"stdout": "", "stderr": "", "retcode": 0}) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + assert nixpkg.refresh_db() is True + + +def test_refresh_db_failure(): + mock_run = MagicMock(return_value={"stdout": "", "stderr": "error", "retcode": 1}) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + assert nixpkg.refresh_db() is False + + +# collect_garbage tests + + +def test_collect_garbage(): + mock_run = MagicMock( + return_value={ + "stdout": "removing old generations\n3 store paths deleted, 150.00 MiB freed", + "retcode": 0, + } + ) + + with patch.dict(nixpkg.__salt__, {"cmd.run_all": mock_run}): + result = nixpkg.collect_garbage() + assert len(result) == 2 + assert "3 store paths deleted" in result[1] + + +# uninstall tests + + +def test_uninstall_delegates_to_remove(): + with patch.object( + nixpkg, "remove", return_value={"vim": {"old": "9.0.1", "new": ""}} + ) as mock_remove: + result = nixpkg.uninstall("vim") + mock_remove.assert_called_once_with(pkgs=("vim",)) + assert "vim" in result