diff --git a/docs/api.md b/docs/api.md index 3cd79f8..e2487d5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,6 +59,16 @@ install an application JAR. ## Managing the cache +```{eval-rst} +.. autofunction:: cjdk.prune_jdks +.. versionadded:: 0.6.0 +``` + +```{eval-rst} +.. autofunction:: cjdk.cache_directory +.. versionadded:: 0.6.0 +``` + ```{eval-rst} .. autofunction:: cjdk.clear_cache .. versionadded:: 0.5.0 diff --git a/docs/changelog.md b/docs/changelog.md index 1460f57..a2405dc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,10 +10,21 @@ See also the section on [versioning](versioning-scheme). ## [Unreleased] +### Added + +- Add `cjdk prune` and `prune_jdks()` to remove obsolete cached JDKs, keeping + the newest of each vendor and major version (configurable with + `--per-vendor`/`--across-vendors` and `--per-major`/`--across-majors`). Pass + `--keep-none` (with `-j` for precision) to remove specific cached JDKs + outright. +- Add `cache_directory()` to the Python API. + ### Changed - `list_vendors()` and `ls-vendors` now filter vendors by OS and architecture, defaulting to the current platform. +- `cjdk clear-cache` now prompts for confirmation before deleting; pass `--yes` + to skip the prompt, or `--dry-run` to preview. `prune` prompts the same way. ### Removed diff --git a/docs/cli.md b/docs/cli.md index 1460ad9..1ee03a8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -118,6 +118,30 @@ shown was on macOS.) ## Managing the cache +### `prune` + +```{command-output} cjdk prune --help +``` + +For example, if both `zulu:26.0.0` and `zulu:26.0.1` are cached, the older one +is removed while the newest of each vendor and major version is kept: + +```text +$ cjdk prune +``` + +To remove specific cached JDKs instead of keeping the newest of each, add +`--keep-none` and narrow the selection with `-j`. For example, to remove all +cached Zulu 26 JDKs: + +```text +$ cjdk -j zulu:26 prune --keep-none +``` + +```{eval-rst} +.. versionadded:: 0.6.0 +``` + (cli-clear-cache)= ### `clear-cache` @@ -127,4 +151,7 @@ shown was on macOS.) ```{eval-rst} .. versionadded:: 0.5.0 +.. versionchanged:: 0.6.0 + Now prompts for confirmation before deleting. Pass ``--yes`` to skip the + prompt, or ``--dry-run`` to preview. ``` diff --git a/src/cjdk/__init__.py b/src/cjdk/__init__.py index 25f1769..8b76dbe 100644 --- a/src/cjdk/__init__.py +++ b/src/cjdk/__init__.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: MIT from ._api import ( + cache_directory, cache_file, cache_jdk, cache_package, @@ -11,6 +12,7 @@ java_home, list_jdks, list_vendors, + prune_jdks, ) from ._exceptions import ( CjdkError, @@ -21,6 +23,7 @@ from ._version import __version__ as __version__ __all__ = [ + "cache_directory", "cache_file", "cache_jdk", "cache_package", @@ -33,4 +36,5 @@ "JdkNotFoundError", "list_jdks", "list_vendors", + "prune_jdks", ] diff --git a/src/cjdk/__main__.py b/src/cjdk/__main__.py index 1b0814e..38f5c32 100644 --- a/src/cjdk/__main__.py +++ b/src/cjdk/__main__.py @@ -285,24 +285,148 @@ def cache_package( ) +@click.command(short_help="Prune obsolete cached JDKs.") +@click.pass_context +@click.option( + "--keep-none", + is_flag=True, + help="Remove every matching JDK, keeping none. Combine with --jdk/-j to " + "remove specific JDKs, e.g. 'cjdk -j zulu:26 prune --keep-none'.", +) +@click.option( + "--per-vendor/--across-vendors", + default=True, + help="Prune each vendor separately (default), or pool all vendors " + "together so only the newest version survives.", +) +@click.option( + "--per-major/--across-majors", + default=True, + help="Keep the newest of each major version (default), or only the single " + "newest version overall.", +) +@click.option( + "--dry-run", + "-n", + is_flag=True, + help="Show what would be removed, without removing anything.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Do not prompt for confirmation before removing.", +) +def prune( + ctx: click.Context, + keep_none: bool, + per_vendor: bool, + per_major: bool, + dry_run: bool, + yes: bool, +) -> None: + """ + Remove obsolete cached JDKs, keeping only the newest of each. + + By default, the newest cached version of each vendor and major version is + kept, and older versions are removed. For example, if both Zulu 26.0.0 and + 26.0.1 are cached, 26.0.0 is removed. + + Use --across-vendors and/or --across-majors to widen the grouping (removing + more), and the common --jdk/-j option to limit pruning to a particular + vendor or version. Only cached JDKs are removed; cached files, packages, + and the index are left untouched. + + Pass --keep-none to remove every matching JDK instead of keeping the newest + of each. Combined with -j this is a precise way to remove specific JDKs + (e.g. 'cjdk -j zulu:26 prune --keep-none'); with no -j it removes all cached + JDKs (but, unlike 'clear-cache', leaves cached files and packages alone). + + When pruning JDKs, ensure that no other processes are using cjdk or the + affected JDKs. + + See 'cjdk --help' for the common options. + """ + if keep_none and not (per_vendor and per_major): + raise click.UsageError( + "--keep-none cannot be combined with --across-vendors or " + "--across-majors (there is nothing to group when keeping none)." + ) + matched = _api.prune_jdks( + **ctx.obj, + per_vendor=per_vendor, + per_major=per_major, + keep_none=keep_none, + dry_run=True, + ) + if not matched: + click.echo("Nothing to prune.") + return + _echo_jdk_list("JDKs to remove:", matched) + if dry_run: + return + if not yes: + click.confirm(f"Remove {len(matched)} JDK(s)?", abort=True) + removed = _api.prune_jdks( + **ctx.obj, + per_vendor=per_vendor, + per_major=per_major, + keep_none=keep_none, + ) + click.echo(f"Removed {len(removed)} JDK(s).") + + @click.command(short_help="Remove all cached files.") @click.pass_context -def clear_cache(ctx: click.Context) -> None: +@click.option( + "--dry-run", + "-n", + is_flag=True, + help="Show what would be removed, without removing anything.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Do not prompt for confirmation before removing.", +) +def clear_cache(ctx: click.Context, dry_run: bool, yes: bool) -> None: """ Remove all cached JDKs, files, and packages from the cache directory. This permanently deletes everything in the cache. Subsequent commands will - re-download any needed files. + re-download any needed files. To remove obsolete or specific JDKs while + leaving other cached items alone, use 'prune'. + + Unless --yes is given, you are prompted to confirm before anything is + deleted. When clearing the cache, ensure that no other processes are using cjdk or the JDKs, files, or packages installed by cjdk. See 'cjdk --help' for the common options (only --cache-dir is relevant). """ + cache_dir = _api.cache_directory(**ctx.obj) + if not cache_dir.exists(): + click.echo(f"Cache directory does not exist: {cache_dir}") + return + if dry_run: + click.echo(f"Would remove entire cache directory: {cache_dir}") + return + if not yes: + click.echo("This will permanently remove the entire cache directory:") + click.echo(f" {cache_dir}") + click.confirm("Proceed?", abort=True) cleared = _api.clear_cache(**ctx.obj) click.echo(f"Cleared cache: {cleared}") +def _echo_jdk_list(header: str, jdks: list[str]) -> None: + click.echo(header) + for jdk in jdks: + click.echo(f" {jdk}") + + # Register current commands. _cli.add_command(java_home) _cli.add_command(exec) @@ -311,6 +435,7 @@ def clear_cache(ctx: click.Context) -> None: _cli.add_command(cache) _cli.add_command(cache_file) _cli.add_command(cache_package) +_cli.add_command(prune) _cli.add_command(clear_cache) # Register hidden/deprecated commands, for backwards compatibility. diff --git a/src/cjdk/_api.py b/src/cjdk/_api.py index 34f456e..f57084d 100644 --- a/src/cjdk/_api.py +++ b/src/cjdk/_api.py @@ -34,6 +34,7 @@ from ._conf import ConfigKwargs __all__ = [ + "cache_directory", "cache_file", "cache_jdk", "cache_package", @@ -42,6 +43,7 @@ "java_home", "list_jdks", "list_vendors", + "prune_jdks", ] @@ -149,6 +151,127 @@ def list_jdks( # type: ignore [misc] # overlap with kwargs return _jdk.matching_jdks(conf, cached_only=cached_only) +def prune_jdks( # type: ignore [misc] # overlap with kwargs + *, + vendor: str | None = None, + version: str | None = None, + per_vendor: bool = True, + per_major: bool = True, + keep_none: bool = False, + dry_run: bool = False, + **kwargs: Unpack[ConfigKwargs], +) -> list[str]: + """ + Remove obsolete cached JDKs, keeping only the newest of each group. + + By default, the newest cached version of every (vendor, major version) is + kept and older versions are removed. Only cached JDKs are affected; cached + files, packages, and the index are left untouched. This should not be + called when other processes may be using cjdk or the JDKs installed by + cjdk. + + Parameters + ---------- + vendor : str, optional + Limit pruning to this JDK vendor (default: all cached vendors). + version : str, optional + Limit pruning to versions matching this expression, such as "17+". + per_vendor : bool, default: True + If True, prune each vendor separately. If False, pool all vendors + together so that only the newest version survives in each group. + per_major : bool, default: True + If True, keep the newest of each major version. If False, keep only the + single newest version per group. + keep_none : bool, default: False + If True, remove every matching JDK, keeping none (this ignores + `per_vendor` and `per_major`). Combined with `vendor`/`version` (or + `jdk`), it removes specific JDKs; with no such filter, it removes all + cached JDKs. + dry_run : bool, default: False + If True, return the obsolete JDKs without removing anything. + + Other Parameters + ---------------- + jdk : str, optional + JDK vendor and version, such as "adoptium:17+". Cannot be specified + together with `vendor` or `version`. + cache_dir : pathlib.Path or str, optional + Override the root cache directory. + index_url : str, optional + Alternative URL for the JDK index. + os : str, optional + Operating system for the JDK (default: current operating system). + arch : str, optional + CPU architecture for the JDK (default: current architecture). + + Returns + ------- + list[str] + The JDKs (vendor:version) that were removed (or, if `dry_run`, that + would be removed). + + Raises + ------ + ConfigError + If configuration is invalid. + InstallError + If fetching the index fails. + CjdkError + If a cached JDK could not be removed. + """ + jdk = kwargs.pop("jdk", None) + if jdk: + parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk) + vendor = vendor or parsed_vendor or None + version = version or parsed_version or None + + if vendor is None: + vendors = sorted(_jdk.available_vendors(_conf.configure(**kwargs))) + else: + vendors = [vendor] + + cached: list[tuple[str, str]] = [] + for v in vendors: + conf = _conf.configure(vendor=v, version=version, **kwargs) + cached.extend((v, ver) for ver in _jdk.cached_jdk_versions(conf)) + + to_prune = _jdk.jdks_to_prune( + cached, + per_vendor=per_vendor, + per_major=per_major, + keep_none=keep_none, + ) + + if dry_run: + return [f"{v}:{ver}" for v, ver in to_prune] + + removed = [] + by_vendor: dict[str, list[str]] = {} + for v, ver in to_prune: + by_vendor.setdefault(v, []).append(ver) + for v, vers in by_vendor.items(): + conf = _conf.configure(vendor=v, version=version, **kwargs) + removed.extend(_jdk.remove_jdks(conf, vers)) + return removed + + +def cache_directory(**kwargs: Unpack[ConfigKwargs]) -> Path: + """ + Return the configured root cache directory. + + Other Parameters + ---------------- + cache_dir : pathlib.Path or str, optional + Override the root cache directory. + + Returns + ------- + pathlib.Path + The root cache directory. + """ + return _conf.configure(**kwargs).cache_dir + + def clear_cache(**kwargs: Unpack[ConfigKwargs]) -> Path: """ Remove all cached files and directories. diff --git a/src/cjdk/_cache.py b/src/cjdk/_cache.py index 1a3d763..1d03cea 100644 --- a/src/cjdk/_cache.py +++ b/src/cjdk/_cache.py @@ -16,6 +16,7 @@ from __future__ import annotations import hashlib +import shutil import sys import time import urllib.parse @@ -27,12 +28,13 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator -from ._exceptions import ConfigError, InstallError +from ._exceptions import CjdkError, ConfigError, InstallError __all__ = [ "atomic_file", "is_cached", "permanent_directory", + "remove", ] @@ -42,6 +44,34 @@ def is_cached(prefix: str, key_url: str, *, cache_dir: Path) -> bool: return _key_directory(cache_dir, key).is_dir() +def remove(prefix: str, key_url: str, *, cache_dir: Path) -> bool: + """ + Remove cached content for the given prefix and URL. + + Both the key directory and its sibling ``.url`` file are removed. Returns + True if a cached key directory was present and removed, False otherwise. + + This should not be called when other processes may be using the cached + content. + """ + if not isinstance(cache_dir, Path): + cache_dir = Path(cache_dir) + key = (prefix, _key_for_url(key_url)) + keydir = _key_directory(cache_dir, key) + url_file = keydir.parent / (keydir.name + ".url") + existed = keydir.is_dir() + try: + if existed: + shutil.rmtree(keydir) + if url_file.exists(): + url_file.unlink() + except OSError as e: + raise CjdkError( + f"Failed to remove cached directory {keydir}: {e}" + ) from e + return existed + + def _key_for_url(url: str | urllib.parse.ParseResult) -> str: """ Return a cache key suitable to cache content retrieved from the given URL. diff --git a/src/cjdk/_index.py b/src/cjdk/_index.py index 9d4c912..aad1d03 100644 --- a/src/cjdk/_index.py +++ b/src/cjdk/_index.py @@ -33,6 +33,7 @@ "jdk_url", "matching_jdk_versions", "resolve_jdk_version", + "version_sort_key", ] @@ -258,6 +259,22 @@ def _is_version_compatible_with_spec( return len(version) >= len(spec) and version[: len(spec)] == spec +def version_sort_key(vendor: str, version: str) -> tuple[int | str, ...]: + """ + Return a normalized, sortable key for the given vendor's version string. + + The key compares element by element, so that, e.g., "26.0.0" sorts before + "26.0.1". Returns an empty tuple for versions that cannot be normalized. + + The first element of the (non-empty) key is the major version. + """ + is_graal = "graalvm" in vendor.lower() + try: + return _normalize_version(version, remove_prefix_1=not is_graal) + except ValueError: + return () + + def matching_jdk_versions(index: Index, conf: Configuration) -> list[str]: """ Return all version strings matching the configuration, sorted by version. diff --git a/src/cjdk/_jdk.py b/src/cjdk/_jdk.py index b2c940e..d2d7ec4 100644 --- a/src/cjdk/_jdk.py +++ b/src/cjdk/_jdk.py @@ -18,9 +18,12 @@ __all__ = [ "available_vendors", + "cached_jdk_versions", "find_home", "install_jdk", + "jdks_to_prune", "matching_jdks", + "remove_jdks", ] @@ -50,21 +53,91 @@ def matching_jdks(conf: Configuration, cached_only: bool = True) -> list[str]: """ Return JDKs matching the configuration, optionally filtered to cached only. """ + if cached_only: + versions = cached_jdk_versions(conf) + else: + index = _index.jdk_index(conf) + versions = _index.matching_jdk_versions(index, conf) + + return [f"{conf.vendor}:{v}" for v in versions] + + +def cached_jdk_versions(conf: Configuration) -> list[str]: + """ + Return the exact version strings of cached JDKs matching the configuration. + + The versions are sorted from oldest to newest. + """ index = _index.jdk_index(conf) versions = _index.matching_jdk_versions(index, conf) + return [ + v + for v in versions + if _cache.is_cached( + _JDK_KEY_PREFIX, + _index.jdk_url(index, conf, v), + cache_dir=conf.cache_dir, + ) + ] + + +def remove_jdks(conf: Configuration, versions: list[str]) -> list[str]: + """ + Remove the given cached JDK versions for the configured vendor. - if cached_only: - versions = [ - v - for v in versions - if _cache.is_cached( - _JDK_KEY_PREFIX, - _index.jdk_url(index, conf, v), - cache_dir=conf.cache_dir, - ) - ] + Returns the list of "vendor:version" strings that were actually removed. + """ + index = _index.jdk_index(conf) + removed = [] + for version in versions: + url = _index.jdk_url(index, conf, version) + if _cache.remove(_JDK_KEY_PREFIX, url, cache_dir=conf.cache_dir): + removed.append(f"{conf.vendor}:{version}") + return removed + + +def jdks_to_prune( + jdks: list[tuple[str, str]], + *, + per_vendor: bool = True, + per_major: bool = True, + keep_none: bool = False, +) -> list[tuple[str, str]]: + """ + Select obsolete JDKs to prune, keeping the newest of each group. - return [f"{conf.vendor}:{v}" for v in versions] + Arguments: + jdks -- A list of (vendor, version) tuples (the cached JDKs). + per_vendor -- If True, prune each vendor separately; if False, pool all + vendors together so that only the newest version survives in + each (remaining) group, regardless of vendor. + per_major -- If True, keep the newest of each major version; if False, + keep only the single newest version per (remaining) group. + keep_none -- If True, prune every JDK, keeping none (per_vendor and + per_major are ignored). + + Within each group, the single newest version is kept; the rest are + returned. The returned list preserves the order of the input. + """ + if keep_none: + return list(jdks) + + keys = [ + _index.version_sort_key(vendor, version) for vendor, version in jdks + ] + + groups: dict[tuple[str | None, int | str | None], list[int]] = {} + for i, (vendor, _version) in enumerate(jdks): + key = keys[i] + group_key = ( + vendor if per_vendor else None, + key[0] if (per_major and key) else None, + ) + groups.setdefault(group_key, []).append(i) + + keep = {max(members, key=lambda i: keys[i]) for members in groups.values()} + + return [jdk for i, jdk in enumerate(jdks) if i not in keep] def install_jdk(conf: Configuration) -> Path: diff --git a/tests/test_cache_remove.py b/tests/test_cache_remove.py new file mode 100644 index 0000000..e1c4ea1 --- /dev/null +++ b/tests/test_cache_remove.py @@ -0,0 +1,45 @@ +# This file is part of cjdk. +# Copyright 2022-25 Board of Regents of the University of Wisconsin System +# SPDX-License-Identifier: MIT + +from cjdk import _cache + + +def test_remove(tmp_path): + cache_dir = tmp_path / "cache" + url = "tgz+https://example.com/jdk.tar.gz" + keydir = cache_dir / "v0" / "jdks" / _cache._key_for_url(url) + keydir.mkdir(parents=True) + (keydir / "file.txt").touch() + url_file = keydir.parent / (keydir.name + ".url") + url_file.write_text(url) + + assert _cache.is_cached("jdks", url, cache_dir=cache_dir) + + removed = _cache.remove("jdks", url, cache_dir=cache_dir) + + assert removed is True + assert not keydir.exists() + assert not url_file.exists() + assert not _cache.is_cached("jdks", url, cache_dir=cache_dir) + + +def test_remove_nonexistent(tmp_path): + cache_dir = tmp_path / "cache" + url = "tgz+https://example.com/jdk.tar.gz" + + removed = _cache.remove("jdks", url, cache_dir=cache_dir) + + assert removed is False # Nothing to remove, but no error + + +def test_remove_without_url_file(tmp_path): + cache_dir = tmp_path / "cache" + url = "tgz+https://example.com/jdk.tar.gz" + keydir = cache_dir / "v0" / "jdks" / _cache._key_for_url(url) + keydir.mkdir(parents=True) + + removed = _cache.remove("jdks", url, cache_dir=cache_dir) + + assert removed is True + assert not keydir.exists() diff --git a/tests/test_prune.py b/tests/test_prune.py new file mode 100644 index 0000000..8a8536d --- /dev/null +++ b/tests/test_prune.py @@ -0,0 +1,102 @@ +# This file is part of cjdk. +# Copyright 2022-25 Board of Regents of the University of Wisconsin System +# SPDX-License-Identifier: MIT + +from cjdk._jdk import jdks_to_prune + + +def test_prune_keeps_newest_per_vendor_major(): + jdks = [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ("corretto", "25.0.2"), + ] + # Default: per vendor, per major. Only the older Zulu 25 is obsolete. + assert jdks_to_prune(jdks) == [("zulu", "25.0.0")] + + +def test_prune_keeps_each_major(): + jdks = [ + ("zulu", "21.0.8"), + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ("zulu", "26.0.1"), + ] + # Each major line keeps its own newest. + assert jdks_to_prune(jdks) == [("zulu", "21.0.8"), ("zulu", "26.0.0")] + + +def test_prune_across_majors(): + jdks = [ + ("zulu", "21.0.8"), + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ("zulu", "26.0.1"), + ] + # Collapsing majors keeps only the single newest Zulu. + assert jdks_to_prune(jdks, per_major=False) == [ + ("zulu", "21.0.8"), + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ] + + +def test_prune_across_vendors(): + jdks = [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ("corretto", "25.0.2"), + ] + # Pooling vendors: only the newest version of major 25 survives. + assert jdks_to_prune(jdks, per_vendor=False) == [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ] + + +def test_prune_across_vendors_and_majors(): + jdks = [ + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ("corretto", "26.0.1"), + ] + # Everything but the single newest is obsolete. + assert jdks_to_prune(jdks, per_vendor=False, per_major=False) == [ + ("zulu", "21.0.9"), + ("zulu", "26.0.0"), + ] + + +def test_prune_nothing_when_distinct_majors(): + jdks = [ + ("zulu", "21.0.9"), + ("adoptium", "17.0.3"), + ] + assert jdks_to_prune(jdks) == [] + + +def test_prune_empty(): + assert jdks_to_prune([]) == [] + + +def test_prune_keep_none(): + jdks = [ + ("zulu", "25.0.0"), + ("zulu", "25.0.1"), + ("corretto", "25.0.2"), + ] + # keep_none prunes everything, ignoring the grouping options. + assert jdks_to_prune(jdks, keep_none=True) == jdks + assert ( + jdks_to_prune(jdks, keep_none=True, per_vendor=False, per_major=False) + == jdks + ) + + +def test_prune_handles_jdk_1_x_prefix(): + # JDK 1.8 normalizes to major 8; keep the newest 1.8. + jdks = [ + ("adoptium", "1.8.0-292"), + ("adoptium", "1.8.0-302"), + ] + assert jdks_to_prune(jdks) == [("adoptium", "1.8.0-292")] diff --git a/tests/test_prune_api.py b/tests/test_prune_api.py new file mode 100644 index 0000000..c836234 --- /dev/null +++ b/tests/test_prune_api.py @@ -0,0 +1,131 @@ +# This file is part of cjdk. +# Copyright 2022-25 Board of Regents of the University of Wisconsin System +# SPDX-License-Identifier: MIT + +import mock_server + +from cjdk import _api, _cache, _jdk + +_INDEX_DATA = { + "linux": { + "amd64": { + "jdk@zulu": { + "25.0.0": "tgz+http://example.com/zulu-25.0.0.tar.gz", + "25.0.1": "tgz+http://example.com/zulu-25.0.1.tar.gz", + "21.0.9": "tgz+http://example.com/zulu-21.0.9.tar.gz", + }, + "jdk@adoptium": { + "17.0.3": "tgz+http://example.com/adoptium-17.0.3.tar.gz", + }, + }, + }, +} + + +def _populate(cache_dir, url): + keydir = cache_dir / "v0" / _jdk._JDK_KEY_PREFIX / _cache._key_for_url(url) + keydir.mkdir(parents=True) + (keydir / "marker").touch() + (keydir.parent / (keydir.name + ".url")).write_text(url) + + +def _populate_all(cache_dir): + for versions in _INDEX_DATA["linux"]["amd64"].values(): + for url in versions.values(): + _populate(cache_dir, url) + + +def _common(server, cache_dir): + return dict( + os="linux", + arch="amd64", + cache_dir=cache_dir, + index_url=server.url("/index.json"), + _allow_insecure_for_testing=True, + ) + + +def test_prune_keep_none_exact(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + # Dry run reports but does not remove. + dry = _api.prune_jdks( + jdk="zulu:25.0.0", keep_none=True, dry_run=True, **common + ) + assert dry == ["zulu:25.0.0"] + assert _api.list_jdks(jdk="zulu", **common) == [ + "zulu:21.0.9", + "zulu:25.0.0", + "zulu:25.0.1", + ] + + removed = _api.prune_jdks(jdk="zulu:25.0.0", keep_none=True, **common) + assert removed == ["zulu:25.0.0"] + assert _api.list_jdks(jdk="zulu", **common) == [ + "zulu:21.0.9", + "zulu:25.0.1", + ] + # Untouched vendor still present. + assert _api.list_jdks(jdk="adoptium", **common) == ["adoptium:17.0.3"] + + +def test_prune_keep_none_by_vendor(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + removed = _api.prune_jdks(jdk="zulu", keep_none=True, **common) + assert sorted(removed) == ["zulu:21.0.9", "zulu:25.0.0", "zulu:25.0.1"] + assert _api.list_jdks(jdk="zulu", **common) == [] + assert _api.list_jdks(jdk="adoptium", **common) == ["adoptium:17.0.3"] + + +def test_prune_keep_none_all(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + # With no vendor filter, keep_none removes every cached JDK. + removed = _api.prune_jdks(keep_none=True, **common) + assert sorted(removed) == [ + "adoptium:17.0.3", + "zulu:21.0.9", + "zulu:25.0.0", + "zulu:25.0.1", + ] + assert _api.list_jdks(jdk="zulu", **common) == [] + assert _api.list_jdks(jdk="adoptium", **common) == [] + + +def test_prune_jdks_default(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + dry = _api.prune_jdks(dry_run=True, **common) + assert dry == ["zulu:25.0.0"] + + removed = _api.prune_jdks(**common) + assert removed == ["zulu:25.0.0"] + # 21.0.9 (different major) and 25.0.1 (newest) survive. + assert _api.list_jdks(jdk="zulu", **common) == [ + "zulu:21.0.9", + "zulu:25.0.1", + ] + + +def test_prune_jdks_across_majors(tmp_path): + cache_dir = tmp_path / "cache" + _populate_all(cache_dir) + with mock_server.start(endpoint="/index.json", data=_INDEX_DATA) as server: + common = _common(server, cache_dir) + + removed = _api.prune_jdks(per_major=False, **common) + assert sorted(removed) == ["zulu:21.0.9", "zulu:25.0.0"] + assert _api.list_jdks(jdk="zulu", **common) == ["zulu:25.0.1"]