From b6fbc6db12d9cc1ce06a33defa5401852853debe Mon Sep 17 00:00:00 2001 From: Raufu Abdulraman Date: Thu, 2 Jul 2026 23:07:35 +0100 Subject: [PATCH] big bang: internal compression before detected files are trashed, uses basic compression of zip and zstd, no new deps --- CHANGELOG.md | 9 +++++ README.md | 24 +++++++++++++ src/devklean/cli/commands/clean.py | 6 +++- src/devklean/cli/parser.py | 5 +++ src/devklean/config/manager.py | 6 ++++ src/devklean/config/models.py | 1 + src/devklean/deletion/compression.py | 27 ++++++++++++++ src/devklean/deletion/metadata.py | 53 ++++++++++++++++++++++++++-- src/devklean/deletion/trash.py | 20 +++++++++-- src/devklean/output/text.py | 4 +++ src/devklean/tui.py | 3 +- tests/test_cli_parser.py | 9 ++++- tests/test_config.py | 10 +++++- tests/test_config_precedence.py | 3 ++ tests/test_deletion.py | 53 +++++++++++++++++++++++++++- tests/test_doctor.py | 34 +++++++++++++++++- tests/test_history.py | 4 +-- tests/test_integrity.py | 32 +++++++++++++++-- tests/test_restore.py | 1 + 19 files changed, 288 insertions(+), 16 deletions(-) create mode 100644 src/devklean/deletion/compression.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d973b..6e0ae4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `--compress` flag (and matching `compress` config default) for `clean`: when + set, eligible directories are zipped into a sibling archive before being + sent to trash, shrinking the footprint of large artifacts like + `node_modules` or `.venv`. Metadata records the archive path and format + (schema version 4); restoring a compressed item currently requires + unpacking the archive by hand after pulling it out of trash. + ## [1.0.1] - 2026-06-30 ### Fixed diff --git a/README.md b/README.md index 47e04a2..66bb6c0 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ devklean clean --dry-run # show what *would* be deleted; delete nothing devklean clean -i # interactive: pick items in a TUI (Linux/macOS only) devklean clean --allow-symlinks # permit deleting symlinked targets (blocked by default) devklean clean -y # skip the y/N prompt (large deletions still require typing DELETE) +devklean clean --compress # zip eligible directories before sending them to trash ``` | Flag | Meaning | @@ -78,6 +79,7 @@ devklean clean -y # skip the y/N prompt (large deletions still req | `-i`, `--interactive` | Choose items in a terminal UI (SPACE select, A all, D none, ENTER confirm, Q quit). **Linux/macOS only** — see [Platform support](#platform-support). | | `--allow-symlinks` | Allow deleting symbolic links. Off by default (symlinks are blocked). | | `-y`, `--yes` | Skip the standard confirmation. Deletions over the size threshold still require typing `DELETE`. | +| `--compress` | Zip eligible directories into a sibling archive before trashing them, shrinking their footprint in trash. Off by default — see [Compression](#compression). | ### `restore` @@ -93,6 +95,11 @@ devklean restore # explains how to recover from the Recycle Bin / Trash - **macOS** — open Trash in Finder and "Put Back". - **Linux** — open Trash in your file manager and restore. +If the item was deleted with `--compress`, what lands in trash is a `.zip` +archive rather than the original directory — restore the archive from trash, +then unpack it to the original path yourself. devklean does not decompress +automatically (yet). + Run `devklean history` to see what was removed and when. ### `history` @@ -163,6 +170,7 @@ exclude = ["node_modules", ".git"] dry_run = false interactive = false default_yes = false # skip the y/N prompt (the large-deletion DELETE gate still applies) +compress = false # zip eligible directories before trashing them theme = "default" # "default" or "mono" confirm_threshold = 1073741824 # bytes; deletions >= this require typing DELETE (default 1 GiB) path = "." @@ -181,6 +189,22 @@ Scalar keys from the project file override the global file; list keys (`exclude` Color follows the `theme` setting and is automatically disabled when output is piped or `NO_COLOR` is set. +## Compression + +Common artifacts (`node_modules`, `.venv`, `.next`, build caches) often compress +to a fraction of their on-disk size. Pass `--compress` (or set `compress = true` +in config) and devklean will zip each eligible directory into a sibling +`.zip` archive and send *that* to trash instead of the raw directory — +shrinking how much space the deletion actually occupies in trash before it's +emptied. + +- Only applies to directories (not symlinks); files are trashed as-is. +- The archive path and format are recorded in deletion metadata, so `history` + and `doctor` see compressed deletions the same as uncompressed ones. +- Off by default, so existing scripts and habits aren't surprised by archives + appearing in trash. +- Restoring a compressed item is manual today — see [`restore`](#restore). + ## Logs `devklean` writes detailed structured logs (commands, scanned/deleted paths, sizes, errors) to: diff --git a/src/devklean/cli/commands/clean.py b/src/devklean/cli/commands/clean.py index 6b15136..384feb0 100644 --- a/src/devklean/cli/commands/clean.py +++ b/src/devklean/cli/commands/clean.py @@ -45,6 +45,7 @@ def run_standard( dry_run: bool, validator: SafetyValidator | None = None, *, + compress: bool = False, default_yes: bool = False, confirm_threshold: int = DEFAULT_LARGE_THRESHOLD, ) -> None: @@ -58,7 +59,7 @@ def run_standard( renderer.aborted() return - result = delete_items(found, total_size, validator=validator) + result = delete_items(found, total_size, validator=validator, compress=compress) renderer.deletion_result(result) @@ -76,6 +77,7 @@ def run_clean( defaults = config.defaults default_yes = getattr(args, "yes", False) or getattr(defaults, "default_yes", False) confirm_threshold = getattr(defaults, "confirm_threshold", DEFAULT_LARGE_THRESHOLD) + compress = getattr(args, "compress", False) or getattr(defaults, "compress", False) if args.interactive: # Interactive mode relies on curses, which is unavailable on Windows. @@ -97,6 +99,7 @@ def run_clean( found, args.dry_run, validator, + compress=compress, confirm_threshold=confirm_threshold, ) else: @@ -105,6 +108,7 @@ def run_clean( found, args.dry_run, validator, + compress=compress, default_yes=default_yes, confirm_threshold=confirm_threshold, ) diff --git a/src/devklean/cli/parser.py b/src/devklean/cli/parser.py index 127b88f..993eb98 100644 --- a/src/devklean/cli/parser.py +++ b/src/devklean/cli/parser.py @@ -36,6 +36,11 @@ def _add_clean_arguments(parser: argparse.ArgumentParser) -> None: action="store_true", help="Show what would be deleted without deleting anything", ) + parser.add_argument( + "--compress", + action="store_true", + help="Compress eligible directories into zip archives before trashing them", + ) parser.add_argument( "-i", "--interactive", diff --git a/src/devklean/config/manager.py b/src/devklean/config/manager.py index 756bb2d..4eb8d79 100644 --- a/src/devklean/config/manager.py +++ b/src/devklean/config/manager.py @@ -20,6 +20,7 @@ _KNOWN_DEFAULTS = { "dry_run", "interactive", + "compress", "path", "default_yes", "theme", @@ -83,6 +84,8 @@ def apply_defaults(self, args, raw_argv: list[str]) -> None: args.dry_run = config.defaults.dry_run if "-i" not in raw_argv and "--interactive" not in raw_argv: args.interactive = config.defaults.interactive + if "--compress" not in raw_argv: + args.compress = config.defaults.compress if getattr(args, "path", None) == "." and not _explicit_path_provided(raw_argv): args.path = os.path.expanduser(config.defaults.path) @@ -149,6 +152,7 @@ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig: merged: dict[str, Any] = { "dry_run": base.dry_run, "interactive": base.interactive, + "compress": base.compress, "path": base.path, "default_yes": base.default_yes, "theme": base.theme, @@ -161,6 +165,8 @@ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig: for key in ("dry_run", "interactive", "default_yes"): if key in section: merged[key] = bool(section[key]) + if "compress" in section: + merged["compress"] = bool(section["compress"]) if "path" in section: merged["path"] = str(section["path"]) if "theme" in section: diff --git a/src/devklean/config/models.py b/src/devklean/config/models.py index effb884..744979b 100644 --- a/src/devklean/config/models.py +++ b/src/devklean/config/models.py @@ -13,6 +13,7 @@ class DefaultsConfig: dry_run: bool = False interactive: bool = False + compress: bool = False path: str = "." default_yes: bool = False theme: str = "default" diff --git a/src/devklean/deletion/compression.py b/src/devklean/deletion/compression.py new file mode 100644 index 0000000..5d23b88 --- /dev/null +++ b/src/devklean/deletion/compression.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +ARCHIVE_FORMAT = "zip" + + +@dataclass(frozen=True) +class CompressionArchive: + path: str + format: str + + +def compress_directory(source: Path) -> CompressionArchive: + """Archive a directory into a sibling zip file and remove the source tree.""" + archive_path = Path( + shutil.make_archive( + str(source), + ARCHIVE_FORMAT, + root_dir=source.parent, + base_dir=source.name, + ) + ) + shutil.rmtree(source) + return CompressionArchive(path=str(archive_path), format=ARCHIVE_FORMAT) diff --git a/src/devklean/deletion/metadata.py b/src/devklean/deletion/metadata.py index e7442de..50c272b 100644 --- a/src/devklean/deletion/metadata.py +++ b/src/devklean/deletion/metadata.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Sequence +from typing import Mapping, Sequence from uuid import uuid4 from devklean.deletion.paths import get_deletion_metadata_dir @@ -14,6 +14,7 @@ # the write side and this validation can't drift; a record with any other value # was written by a removed backend and is treated as corrupt. TRASH_STRATEGY = "trash" +SCHEMA_VERSION = 4 @dataclass(frozen=True) @@ -30,6 +31,18 @@ def to_dict(self) -> dict[str, object]: } +@dataclass(frozen=True) +class DeletionArchive: + path: str + format: str + + def to_dict(self) -> dict[str, object]: + return { + "path": self.path, + "format": self.format, + } + + @dataclass(frozen=True) class DeletionMetadataRecord: schema_version: int @@ -38,9 +51,10 @@ class DeletionMetadataRecord: timestamp: str strategy: str item: DeletionMetadataItem + archive: DeletionArchive | None = None def to_dict(self) -> dict[str, object]: - return { + payload = { "schema_version": self.schema_version, "deletion": { "id": self.deletion_id, @@ -50,6 +64,9 @@ def to_dict(self) -> dict[str, object]: }, "item": self.item.to_dict(), } + if self.archive is not None: + payload["archive"] = self.archive.to_dict() + return payload @dataclass(frozen=True) @@ -89,6 +106,7 @@ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord: original_path = item.get("original_path") display_name = item.get("display_name") size = item.get("size") + archive_data = data.get("archive") # Records predating the schema_version field are treated as v1. Any integer # version is accepted as-is; there are no migrations yet. @@ -106,6 +124,16 @@ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord: ): raise ValueError("missing or wrong-typed metadata fields") + archive: DeletionArchive | None = None + if archive_data is not None: + if not isinstance(archive_data, dict): + raise ValueError("missing or invalid 'archive' section") + archive_path = archive_data.get("path") + archive_format = archive_data.get("format") + if not isinstance(archive_path, str) or not isinstance(archive_format, str): + raise ValueError("missing or wrong-typed archive fields") + archive = DeletionArchive(path=archive_path, format=archive_format) + if strategy != TRASH_STRATEGY: raise ValueError(f"unrecognized strategy {strategy!r}") @@ -120,6 +148,7 @@ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord: display_name=display_name, size=size, ), + archive=archive, ) @@ -174,6 +203,7 @@ def record_successes( items: Sequence[CleanableItem], result: DeleteResult, strategy: str, + archives: Mapping[str, DeletionArchive | Mapping[str, str]] | None = None, ) -> None: deleted_paths = set(result.deleted) if not deleted_paths: @@ -183,13 +213,17 @@ def record_successes( run_id = uuid4().hex timestamp = datetime.now(timezone.utc).isoformat() + archives = archives or {} for item in items: if item.path not in deleted_paths: continue + archive_value = archives.get(item.path) + archive = _coerce_archive(archive_value) + record = DeletionMetadataRecord( - schema_version=3, + schema_version=SCHEMA_VERSION, deletion_id=uuid4().hex, run_id=run_id, timestamp=timestamp, @@ -199,8 +233,21 @@ def record_successes( display_name=item.display_label, size=item.size, ), + archive=archive, ) stamp = record.timestamp.replace(":", "").replace("+00:00", "Z") filename = f"{stamp}_{record.deletion_id}.json" path = self._storage_dir / filename path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8") + + +def _coerce_archive(value: DeletionArchive | Mapping[str, str] | None) -> DeletionArchive | None: + if value is None: + return None + if isinstance(value, DeletionArchive): + return value + path = value.get("path") + format_name = value.get("format") + if not isinstance(path, str) or not isinstance(format_name, str): + raise ValueError("archive metadata must contain string path and format") + return DeletionArchive(path=path, format=format_name) diff --git a/src/devklean/deletion/trash.py b/src/devklean/deletion/trash.py index 656562d..7701044 100644 --- a/src/devklean/deletion/trash.py +++ b/src/devklean/deletion/trash.py @@ -1,10 +1,12 @@ from __future__ import annotations from collections.abc import Sequence +from pathlib import Path from send2trash import send2trash -from devklean.deletion.metadata import TRASH_STRATEGY, MetadataManager +from devklean.deletion.compression import compress_directory +from devklean.deletion.metadata import TRASH_STRATEGY, DeletionArchive, MetadataManager from devklean.deletion.safety import SafetyValidator from devklean.logging_setup import get_logger from devklean.models import CleanableItem, DeleteFailure, DeleteResult @@ -22,6 +24,7 @@ def delete_items( validator: SafetyValidator | None = None, metadata_manager: MetadataManager | None = None, dry_run: bool = False, + compress: bool = False, ) -> DeleteResult: """Validate, then move safe items to the native OS trash via ``send2trash``. @@ -56,9 +59,15 @@ def delete_items( deleted: list[str] = [] failures: list[DeleteFailure] = [] + archives: dict[str, DeletionArchive] = {} for item in safe: try: - send2trash(item.path) + trash_path = item.path + if compress and _should_compress(item): + archive = compress_directory(Path(item.path)) + archives[item.path] = DeletionArchive(path=archive.path, format=archive.format) + trash_path = archive.path + send2trash(trash_path) deleted.append(item.path) except OSError as exc: # TrashPermissionError subclasses OSError; ENOENT/EACCES and @@ -85,5 +94,10 @@ def delete_items( ) manager = metadata_manager or MetadataManager() - manager.record_successes(items, result, STRATEGY_NAME) + manager.record_successes(items, result, STRATEGY_NAME, archives=archives) return result + + +def _should_compress(item: CleanableItem) -> bool: + source = Path(item.path) + return source.is_dir() and not source.is_symlink() diff --git a/src/devklean/output/text.py b/src/devklean/output/text.py index 4f1224b..020f689 100644 --- a/src/devklean/output/text.py +++ b/src/devklean/output/text.py @@ -179,5 +179,9 @@ def restore_help(self) -> None: c.detail(" • Windows — open the Recycle Bin and restore the item.") c.detail(" • macOS — open Trash in Finder and 'Put Back'.") c.detail(" • Linux — open Trash in your file manager and restore.") + c.detail( + "If compression was enabled, restore the archive from trash and unpack it " + "to the original path." + ) self._println() c.detail("Run `devklean history` to see what was removed and when.") diff --git a/src/devklean/tui.py b/src/devklean/tui.py index 8c4d5ca..725f7af 100644 --- a/src/devklean/tui.py +++ b/src/devklean/tui.py @@ -100,6 +100,7 @@ def run_interactive( dry_run: bool, validator: SafetyValidator | None = None, *, + compress: bool = False, confirm_threshold: int = DEFAULT_LARGE_THRESHOLD, ) -> None: import curses # Unix-only; imported lazily so this module loads on Windows. @@ -128,5 +129,5 @@ def run_interactive( renderer.aborted() return - result = delete_items(selected, total_size, validator=validator) + result = delete_items(selected, total_size, validator=validator, compress=compress) renderer.deletion_result(result) diff --git a/tests/test_cli_parser.py b/tests/test_cli_parser.py index b5e367c..73b4aa9 100644 --- a/tests/test_cli_parser.py +++ b/tests/test_cli_parser.py @@ -2,7 +2,7 @@ from __future__ import annotations -from devklean.cli.parser import default_command_for_flags, resolve_bare_invocation +from devklean.cli.parser import build_parser, default_command_for_flags, resolve_bare_invocation def test_bare_invocation_defaults_to_clean() -> None: @@ -55,3 +55,10 @@ def test_default_command_for_flags() -> None: assert default_command_for_flags(["--dry-run", "."]) == "scan" assert default_command_for_flags(["--dry-run", "-i"]) == "clean" assert default_command_for_flags(["-i"]) == "clean" + + +def test_clean_parser_accepts_compress_flag() -> None: + args = build_parser().parse_args(["clean", "--compress"]) + + assert args.command == "clean" + assert args.compress is True diff --git a/tests/test_config.py b/tests/test_config.py index 219d3dd..79d75f8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,7 @@ def test_config_manager_uses_defaults_when_missing(tmp_path: Path) -> None: assert config.ignored_directories == () assert config.defaults.dry_run is False assert config.defaults.interactive is False + assert config.defaults.compress is False @pytest.mark.skipif(sys.platform == "win32", reason="POSIX absolute path literals") @@ -32,6 +33,7 @@ def test_config_manager_merges_user_settings(tmp_path: Path) -> None: [defaults] dry_run = true interactive = true +compress = true path = "~/projects" [targets] @@ -58,6 +60,7 @@ def test_config_manager_merges_user_settings(tmp_path: Path) -> None: assert config.ignored_directories == (".git",) assert config.defaults.dry_run is True assert config.defaults.interactive is True + assert config.defaults.compress is True assert config.defaults.path == "~/projects" @@ -98,7 +101,10 @@ def test_scan_settings_from_app_config(tmp_path: Path) -> None: def test_apply_defaults_respects_explicit_cli_flags(tmp_path: Path) -> None: config_path = tmp_path / "config.toml" - config_path.write_text("[defaults]\ndry_run = true\ninteractive = true\n", encoding="utf-8") + config_path.write_text( + "[defaults]\ndry_run = true\ninteractive = true\ncompress = true\n", + encoding="utf-8", + ) manager = ConfigManager(config_path=config_path) config = manager.load() @@ -107,6 +113,7 @@ class Args: path = "." dry_run = True interactive = False + compress = False _config = config args = Args() @@ -114,6 +121,7 @@ class Args: assert args.dry_run is True assert args.interactive is True + assert args.compress is True def test_scan_honors_excluded_and_custom_targets(tmp_path: Path) -> None: diff --git a/tests/test_config_precedence.py b/tests/test_config_precedence.py index a15f78f..b56c451 100644 --- a/tests/test_config_precedence.py +++ b/tests/test_config_precedence.py @@ -22,12 +22,14 @@ def test_new_default_keys_parsed(tmp_path: Path) -> None: """ [defaults] default_yes = true +compress = true theme = "mono" confirm_threshold = 5368709120 """, ) config = ConfigManager(config_path=cfg_path, project_dir=tmp_path).load() assert config.defaults.default_yes is True + assert config.defaults.compress is True assert config.defaults.theme == "mono" assert config.defaults.confirm_threshold == 5368709120 @@ -35,6 +37,7 @@ def test_new_default_keys_parsed(tmp_path: Path) -> None: def test_default_keys_have_sane_defaults(tmp_path: Path) -> None: config = ConfigManager(config_path=tmp_path / "missing.toml", project_dir=tmp_path).load() assert config.defaults.default_yes is False + assert config.defaults.compress is False assert config.defaults.theme == "default" assert config.defaults.confirm_threshold == 1024**3 diff --git a/tests/test_deletion.py b/tests/test_deletion.py index 629fa08..e12bc53 100644 --- a/tests/test_deletion.py +++ b/tests/test_deletion.py @@ -29,6 +29,32 @@ def test_delete_items_delegates_to_send2trash(tmp_path: Path, fake_trash) -> Non assert result.total_size == 1024 +def test_delete_items_compresses_directory_before_trashing(tmp_path: Path, monkeypatch) -> None: + source = tmp_path / "workspace" / "node_modules" + source.mkdir(parents=True) + for index in range(64): + (source / f"file-{index}.txt").write_text("A" * 2048, encoding="utf-8") + + trashed: list[str] = [] + + def _record_only(path) -> None: + trashed.append(str(path)) + + monkeypatch.setattr("devklean.deletion.trash.send2trash", _record_only) + + item = CleanableItem(str(source), "node_modules", 64 * 2048, "Node.js") + manager = MetadataManager(storage_dir=tmp_path / "m") + + result = delete_items([item], item.size, metadata_manager=manager, compress=True) + + assert result.deleted == (str(source),) + assert trashed == [str(source.with_suffix(".zip"))] + assert not source.exists() + archive = source.with_suffix(".zip") + assert archive.exists() + assert archive.stat().st_size < item.size + + def test_delete_items_does_not_call_send2trash_on_dry_run(tmp_path: Path, fake_trash) -> None: source = tmp_path / "workspace" / "node_modules" source.mkdir(parents=True) @@ -174,7 +200,7 @@ def _selective(path) -> None: payload = json.loads(records[0].read_text(encoding="utf-8")) assert result.deleted == ("/tmp/a",) - assert payload["schema_version"] == 3 + assert payload["schema_version"] == 4 assert payload["deletion"]["strategy"] == "trash" assert isinstance(payload["deletion"]["run_id"], str) and payload["deletion"]["run_id"] assert payload["item"]["original_path"] == "/tmp/a" @@ -182,6 +208,31 @@ def _selective(path) -> None: assert payload["item"]["size"] == 10 +def test_metadata_manager_records_archive_details(tmp_path: Path) -> None: + storage_dir = tmp_path / "metadata" + manager = MetadataManager(storage_dir=storage_dir) + item = CleanableItem("/tmp/a", "a", 10, "A") + result = DeleteResult(deleted=("/tmp/a",), failed=(), total_size=10) + + manager.record_successes( + [item], + result, + "trash", + archives={ + "/tmp/a": { + "path": "/tmp/a.zip", + "format": "zip", + } + }, + ) + + records = sorted(storage_dir.glob("*.json")) + payload = json.loads(records[0].read_text(encoding="utf-8")) + + assert payload["schema_version"] == 4 + assert payload["archive"] == {"path": "/tmp/a.zip", "format": "zip"} + + def test_metadata_manager_skips_failed_deletions(tmp_path: Path) -> None: storage_dir = tmp_path / "metadata" manager = MetadataManager(storage_dir=storage_dir) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ce9274c..6033d51 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -22,7 +22,7 @@ def _meta_dir(tmp_path: Path) -> Path: def _valid(directory: Path, name: str, *, trash_path: str | None) -> None: directory.mkdir(parents=True, exist_ok=True) payload = { - "schema_version": 3, + "schema_version": 4, "deletion": { "id": name, "run_id": "run1", @@ -132,6 +132,38 @@ def test_doctor_does_not_flag_records_with_missing_trash(tmp_path, monkeypatch, assert (meta / "gone.json").exists() +def test_doctor_accepts_compressed_records(tmp_path, monkeypatch, capsys) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + meta = _meta_dir(tmp_path) + meta.mkdir(parents=True, exist_ok=True) + (meta / "compressed.json").write_text( + json.dumps( + { + "schema_version": 4, + "deletion": { + "id": "compressed", + "run_id": "run1", + "timestamp": "2026-06-28T14:00:00+00:00", + "strategy": "trash", + }, + "item": { + "original_path": "/tmp/compressed", + "display_name": "compressed", + "size": 100, + }, + "archive": {"path": "/tmp/compressed.zip", "format": "zip"}, + } + ), + encoding="utf-8", + ) + + code = run_doctor(_args(), TextRenderer(), None) + + out = capsys.readouterr().out + assert code == 0 + assert "healthy" in out.lower() + + def test_doctor_command_end_to_end(tmp_path) -> None: meta = _meta_dir(tmp_path) _corrupt(meta, "bad") diff --git a/tests/test_history.py b/tests/test_history.py index cd8c083..f733fd0 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -31,7 +31,7 @@ def _stored( deletion_id: str = "d0", ) -> StoredDeletionMetadata: record = DeletionMetadataRecord( - schema_version=3, + schema_version=4, deletion_id=deletion_id, run_id=run_id, timestamp=timestamp, @@ -181,7 +181,7 @@ def test_text_renderer_history_corrupt_note_points_to_doctor(capsys) -> None: def _write_metadata(directory: Path, *, deletion_id: str, run_id, size: int) -> None: directory.mkdir(parents=True, exist_ok=True) payload = { - "schema_version": 3, + "schema_version": 4, "deletion": { "id": deletion_id, "run_id": run_id, diff --git a/tests/test_integrity.py b/tests/test_integrity.py index 572187c..5892ff3 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -9,9 +9,16 @@ from devklean.deletion.metadata import CorruptMetadata, MetadataManager -def _valid_payload(*, deletion_id: str, trash_path: str | None, size: int = 100) -> dict: +def _valid_payload( + *, + deletion_id: str, + trash_path: str | None, + size: int = 100, + archive_path: str | None = None, + compression_format: str | None = None, +) -> dict: payload = { - "schema_version": 3, + "schema_version": 4, "deletion": { "id": deletion_id, "run_id": "run1", @@ -26,6 +33,8 @@ def _valid_payload(*, deletion_id: str, trash_path: str | None, size: int = 100) } if trash_path is not None: payload["trash"] = {"path": trash_path} + if archive_path is not None: + payload["archive"] = {"path": archive_path, "format": compression_format or "zip"} return payload @@ -145,3 +154,22 @@ def test_check_integrity_does_not_treat_missing_trash_as_a_problem(tmp_path: Pat assert report.healthy assert len(report.valid) == 1 + + +def test_check_integrity_accepts_compressed_metadata(tmp_path: Path) -> None: + meta = tmp_path / "meta" + _write( + meta, + "compressed.json", + _valid_payload( + deletion_id="c", + trash_path=None, + archive_path="/tmp/c.zip", + compression_format="zip", + ), + ) + + report = check_integrity(MetadataManager(storage_dir=meta)) + + assert report.healthy + assert len(report.valid) == 1 diff --git a/tests/test_restore.py b/tests/test_restore.py index 34dcaeb..fb42f0f 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -24,6 +24,7 @@ def test_restore_explains_native_trash_recovery(capsys) -> None: # points the user at the per-platform recovery path and at history assert "recycle bin" in out assert "history" in out + assert "archive" in out def test_restore_uses_injected_renderer() -> None: