devklean 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. devklean/__init__.py +6 -0
  2. devklean/__main__.py +4 -0
  3. devklean/_version.py +8 -0
  4. devklean/cli/__init__.py +3 -0
  5. devklean/cli/commands/__init__.py +4 -0
  6. devklean/cli/commands/clean.py +106 -0
  7. devklean/cli/commands/common.py +38 -0
  8. devklean/cli/commands/doctor.py +43 -0
  9. devklean/cli/commands/history.py +14 -0
  10. devklean/cli/commands/restore.py +20 -0
  11. devklean/cli/commands/scan.py +16 -0
  12. devklean/cli/confirmation.py +51 -0
  13. devklean/cli/dispatcher.py +45 -0
  14. devklean/cli/main.py +61 -0
  15. devklean/cli/parser.py +164 -0
  16. devklean/config/__init__.py +15 -0
  17. devklean/config/defaults.py +14 -0
  18. devklean/config/manager.py +226 -0
  19. devklean/config/models.py +48 -0
  20. devklean/config/paths.py +24 -0
  21. devklean/config/targets.py +23 -0
  22. devklean/deletion/__init__.py +17 -0
  23. devklean/deletion/history.py +66 -0
  24. devklean/deletion/integrity.py +39 -0
  25. devklean/deletion/metadata.py +198 -0
  26. devklean/deletion/paths.py +24 -0
  27. devklean/deletion/safety.py +176 -0
  28. devklean/deletion/trash.py +89 -0
  29. devklean/formatting.py +31 -0
  30. devklean/logging_setup.py +74 -0
  31. devklean/models.py +34 -0
  32. devklean/output/__init__.py +5 -0
  33. devklean/output/base.py +38 -0
  34. devklean/output/console.py +94 -0
  35. devklean/output/history_payload.py +34 -0
  36. devklean/output/json.py +75 -0
  37. devklean/output/scan_payload.py +51 -0
  38. devklean/output/sorting.py +12 -0
  39. devklean/output/text.py +183 -0
  40. devklean/output/theme.py +46 -0
  41. devklean/scanner/__init__.py +17 -0
  42. devklean/scanner/filters.py +32 -0
  43. devklean/scanner/scanner.py +170 -0
  44. devklean/tui.py +126 -0
  45. devklean-1.0.0.dist-info/METADATA +257 -0
  46. devklean-1.0.0.dist-info/RECORD +49 -0
  47. devklean-1.0.0.dist-info/WHEEL +4 -0
  48. devklean-1.0.0.dist-info/entry_points.txt +2 -0
  49. devklean-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Sequence
8
+
9
+ from devklean.models import CleanableItem
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SafetyViolation:
14
+ """A single safety rule that a path violated."""
15
+
16
+ rule: str
17
+ path: str
18
+ message: str
19
+
20
+
21
+ def _posix_protected_paths() -> set[str]:
22
+ paths = {
23
+ "/",
24
+ "/bin",
25
+ "/sbin",
26
+ "/boot",
27
+ "/dev",
28
+ "/etc",
29
+ "/lib",
30
+ "/lib32",
31
+ "/lib64",
32
+ "/proc",
33
+ "/sys",
34
+ "/usr",
35
+ "/var",
36
+ "/opt",
37
+ "/root",
38
+ "/run",
39
+ "/srv",
40
+ }
41
+ if sys.platform == "darwin":
42
+ paths |= {
43
+ "/System",
44
+ "/Library",
45
+ "/Applications",
46
+ "/private",
47
+ "/cores",
48
+ "/Volumes",
49
+ }
50
+ return paths
51
+
52
+
53
+ def _windows_protected_paths() -> set[str]:
54
+ paths: set[str] = set()
55
+ system_root = os.environ.get("SystemRoot")
56
+ if system_root:
57
+ paths.add(system_root)
58
+ system_drive = os.environ.get("SystemDrive", "C:")
59
+ paths.add(system_drive + "\\")
60
+ paths.add(system_drive + "\\Program Files")
61
+ paths.add(system_drive + "\\Program Files (x86)")
62
+ return paths
63
+
64
+
65
+ def protected_system_paths() -> frozenset[str]:
66
+ """Return the platform-aware set of protected system directories.
67
+
68
+ Paths are normalized (and lower-cased on Windows) for exact-match
69
+ comparison against a resolved real path.
70
+ """
71
+ raw = _windows_protected_paths() if os.name == "nt" else _posix_protected_paths()
72
+ return frozenset(_normalize(path) for path in raw)
73
+
74
+
75
+ def _normalize(path: str) -> str:
76
+ normalized = os.path.normpath(path)
77
+ if os.name == "nt":
78
+ return normalized.lower()
79
+ return normalized
80
+
81
+
82
+ class SafetyValidator:
83
+ """The single place safety checks live for all deletion strategies.
84
+
85
+ Every rule is checked here so strategies never duplicate validation.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ allow_symlinks: bool = False,
91
+ protected_paths: frozenset[str] | None = None,
92
+ ) -> None:
93
+ self._allow_symlinks = allow_symlinks
94
+ # Normalize whatever set we hold so injected protected_paths match the
95
+ # same way the default set does (_normalize is idempotent). Without this
96
+ # an injected raw path never matches the normalized candidate on Windows,
97
+ # where _normalize also lower-cases.
98
+ raw = protected_paths if protected_paths is not None else protected_system_paths()
99
+ self._protected = frozenset(_normalize(path) for path in raw)
100
+
101
+ def validate(self, path: str) -> SafetyViolation | None:
102
+ """Return the first violated rule for ``path``, or None if safe.
103
+
104
+ Structural location rules (root, home, mount, protected) are checked
105
+ before the symlink rule, and against both the literal and the resolved
106
+ path. This matters on macOS, where ``/etc``, ``/var``, and ``/tmp`` are
107
+ themselves symlinks into ``/private``: such a path must be reported as a
108
+ protected system directory — and must not be bypassable with
109
+ ``--allow-symlinks`` — rather than as a plain symbolic link.
110
+ """
111
+ real = os.path.realpath(path)
112
+
113
+ if real == os.path.abspath(os.sep) or self._normalized(real) == os.sep:
114
+ return SafetyViolation(
115
+ rule="filesystem_root",
116
+ path=path,
117
+ message=f"Refusing to delete '{path}': it is the filesystem root.",
118
+ )
119
+
120
+ home = self._home_dir()
121
+ if home is not None and self._normalized(real) == home:
122
+ return SafetyViolation(
123
+ rule="home_directory",
124
+ path=path,
125
+ message=f"Refusing to delete '{path}': it is your home directory.",
126
+ )
127
+
128
+ if os.path.ismount(real):
129
+ return SafetyViolation(
130
+ rule="mount_point",
131
+ path=path,
132
+ message=f"Refusing to delete '{path}': it is a mounted drive root.",
133
+ )
134
+
135
+ if self._normalized(path) in self._protected or self._normalized(real) in self._protected:
136
+ return SafetyViolation(
137
+ rule="protected_system_directory",
138
+ path=path,
139
+ message=f"Refusing to delete '{path}': protected system directory.",
140
+ )
141
+
142
+ if not self._allow_symlinks and os.path.islink(path):
143
+ return SafetyViolation(
144
+ rule="symlink",
145
+ path=path,
146
+ message=(
147
+ f"Refusing to delete '{path}': it is a symbolic link "
148
+ f"(use --allow-symlinks to override)."
149
+ ),
150
+ )
151
+
152
+ return None
153
+
154
+ def partition(
155
+ self,
156
+ items: Sequence[CleanableItem],
157
+ ) -> tuple[list[CleanableItem], list[tuple[CleanableItem, SafetyViolation]]]:
158
+ """Split items into (safe, blocked) where blocked carries the violation."""
159
+ safe: list[CleanableItem] = []
160
+ blocked: list[tuple[CleanableItem, SafetyViolation]] = []
161
+ for item in items:
162
+ violation = self.validate(item.path)
163
+ if violation is None:
164
+ safe.append(item)
165
+ else:
166
+ blocked.append((item, violation))
167
+ return safe, blocked
168
+
169
+ def _normalized(self, real: str) -> str:
170
+ return _normalize(real)
171
+
172
+ def _home_dir(self) -> str | None:
173
+ try:
174
+ return _normalize(os.path.realpath(Path.home()))
175
+ except (RuntimeError, OSError):
176
+ return None
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from send2trash import send2trash
6
+
7
+ from devklean.deletion.metadata import MetadataManager
8
+ from devklean.deletion.safety import SafetyValidator
9
+ from devklean.logging_setup import get_logger
10
+ from devklean.models import CleanableItem, DeleteFailure, DeleteResult
11
+
12
+ # The single deletion backend is the native OS trash (Recycle Bin on Windows,
13
+ # ~/.Trash on macOS, the freedesktop trash on Linux) via send2trash. There is
14
+ # only one method, so the name recorded in metadata/history is a literal.
15
+ STRATEGY_NAME = "trash"
16
+
17
+
18
+ def delete_items(
19
+ items: Sequence[CleanableItem],
20
+ total_size: int,
21
+ *,
22
+ validator: SafetyValidator | None = None,
23
+ metadata_manager: MetadataManager | None = None,
24
+ dry_run: bool = False,
25
+ ) -> DeleteResult:
26
+ """Validate, then move safe items to the native OS trash via ``send2trash``.
27
+
28
+ Safety validation runs first; only validated ("safe") items are ever passed
29
+ to ``send2trash``. Under ``dry_run`` the function returns the planned result
30
+ *before any ``send2trash`` call is reachable* — the structural guarantee
31
+ that a dry run performs no filesystem operations. Successful deletions are
32
+ recorded in the metadata store (used by ``history`` and ``doctor``).
33
+ """
34
+ validator = validator or SafetyValidator()
35
+ safe, blocked = validator.partition(items)
36
+ blocked_failures = tuple(
37
+ DeleteFailure(path=item.path, error=violation.message) for item, violation in blocked
38
+ )
39
+ safe_total = sum(item.size for item in safe)
40
+ logger = get_logger()
41
+
42
+ if dry_run:
43
+ # Structural dry-run guard: no send2trash call below is reachable.
44
+ result = DeleteResult(
45
+ deleted=tuple(item.path for item in safe),
46
+ failed=blocked_failures,
47
+ total_size=safe_total,
48
+ )
49
+ logger.info(
50
+ "dry-run plan strategy=%s would_delete=%d size=%d",
51
+ STRATEGY_NAME,
52
+ result.deleted_count,
53
+ result.total_size,
54
+ )
55
+ return result
56
+
57
+ deleted: list[str] = []
58
+ failures: list[DeleteFailure] = []
59
+ for item in safe:
60
+ try:
61
+ send2trash(item.path)
62
+ deleted.append(item.path)
63
+ except OSError as exc:
64
+ # TrashPermissionError subclasses OSError; ENOENT/EACCES and
65
+ # platform-specific failures surface here too. Report the path and
66
+ # keep going so one bad item never aborts the batch.
67
+ failures.append(DeleteFailure(path=item.path, error=str(exc)))
68
+
69
+ result = DeleteResult(
70
+ deleted=tuple(deleted),
71
+ failed=tuple(failures) + blocked_failures,
72
+ total_size=safe_total,
73
+ )
74
+
75
+ for path in result.deleted:
76
+ logger.info("deleted strategy=%s path=%s", STRATEGY_NAME, path)
77
+ for failure in result.failed:
78
+ logger.warning("delete failed path=%s error=%s", failure.path, failure.error)
79
+ logger.info(
80
+ "deletion summary strategy=%s deleted=%d failed=%d size=%d",
81
+ STRATEGY_NAME,
82
+ result.deleted_count,
83
+ result.failed_count,
84
+ result.total_size,
85
+ )
86
+
87
+ manager = metadata_manager or MetadataManager()
88
+ manager.record_successes(items, result, STRATEGY_NAME)
89
+ return result
devklean/formatting.py ADDED
@@ -0,0 +1,31 @@
1
+ from datetime import datetime
2
+
3
+
4
+ def format_size(size_bytes: float) -> str:
5
+ for unit in ["B", "KB", "MB", "GB"]:
6
+ if size_bytes < 1024:
7
+ return f"{size_bytes:.1f} {unit}"
8
+ size_bytes /= 1024
9
+ return f"{size_bytes:.1f} TB"
10
+
11
+
12
+ def format_timestamp(value: str) -> str:
13
+ """Render a stored UTC ISO timestamp as local ``YYYY-MM-DD HH:MM``.
14
+
15
+ Falls back to the raw value if it cannot be parsed.
16
+ """
17
+ try:
18
+ parsed = datetime.fromisoformat(value)
19
+ except ValueError:
20
+ return value
21
+ return parsed.astimezone().strftime("%Y-%m-%d %H:%M")
22
+
23
+
24
+ def truncate(text: str, max_len: int) -> str:
25
+ if max_len <= 0:
26
+ return ""
27
+ if len(text) <= max_len:
28
+ return text
29
+ if max_len <= 3:
30
+ return text[:max_len]
31
+ return text[: max_len - 3] + "..."
@@ -0,0 +1,74 @@
1
+ """File-based structured logging, kept entirely separate from terminal output.
2
+
3
+ The renderers own everything the user sees on stdout/stderr. This logger writes
4
+ detail to a rotating file and never attaches a console handler, so the two
5
+ concerns cannot interfere.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import sys
13
+ from logging.handlers import RotatingFileHandler
14
+ from pathlib import Path
15
+
16
+ _LOGGER_NAME = "devklean"
17
+ _MAX_BYTES = 1_000_000
18
+ _BACKUP_COUNT = 5
19
+
20
+
21
+ def get_log_path() -> Path:
22
+ """Return the rotating log file path (XDG cache / Windows LOCALAPPDATA aware).
23
+
24
+ An explicit ``XDG_CACHE_HOME`` override is honored on every platform; the
25
+ Windows-native ``LOCALAPPDATA`` location is only the fallback when it is unset.
26
+ """
27
+ cache = os.environ.get("XDG_CACHE_HOME")
28
+ if cache:
29
+ return Path(cache) / "devklean" / "logs" / "latest.log"
30
+
31
+ if sys.platform == "win32":
32
+ base = os.environ.get("LOCALAPPDATA")
33
+ root = Path(base) if base else Path.home() / "AppData" / "Local"
34
+ return root / "devklean" / "logs" / "latest.log"
35
+
36
+ return Path.home() / ".cache" / "devklean" / "logs" / "latest.log"
37
+
38
+
39
+ def get_logger() -> logging.Logger:
40
+ return logging.getLogger(_LOGGER_NAME)
41
+
42
+
43
+ def configure_logging(level: int = logging.INFO) -> logging.Logger:
44
+ """Configure the devklean file logger. Idempotent and re-pointable."""
45
+ logger = get_logger()
46
+ logger.setLevel(level)
47
+ logger.propagate = False
48
+
49
+ # Reset handlers so repeated calls (and tests pointing elsewhere) re-target.
50
+ for handler in list(logger.handlers):
51
+ logger.removeHandler(handler)
52
+ handler.close()
53
+
54
+ try:
55
+ log_path = get_log_path()
56
+ log_path.parent.mkdir(parents=True, exist_ok=True)
57
+ handler = RotatingFileHandler(
58
+ log_path, maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, encoding="utf-8"
59
+ )
60
+ handler.setFormatter(
61
+ logging.Formatter(
62
+ fmt="%(asctime)s %(levelname)s %(message)s",
63
+ datefmt="%Y-%m-%dT%H:%M:%S%z",
64
+ )
65
+ )
66
+ logger.addHandler(handler)
67
+ except OSError:
68
+ # Logging must never break the CLI; degrade to no file logging.
69
+ logger.addHandler(logging.NullHandler())
70
+ return logger
71
+
72
+
73
+ def log_invocation(argv: list[str], command: str | None) -> None:
74
+ get_logger().info("invoke command=%s argv=%s", command, " ".join(argv))
devklean/models.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class CleanableItem:
8
+ """A discovered directory that devklean can remove."""
9
+
10
+ path: str
11
+ name: str
12
+ size: int
13
+ display_label: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DeleteFailure:
18
+ path: str
19
+ error: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class DeleteResult:
24
+ deleted: tuple[str, ...]
25
+ failed: tuple[DeleteFailure, ...]
26
+ total_size: int
27
+
28
+ @property
29
+ def deleted_count(self) -> int:
30
+ return len(self.deleted)
31
+
32
+ @property
33
+ def failed_count(self) -> int:
34
+ return len(self.failed)
@@ -0,0 +1,5 @@
1
+ from devklean.output.base import Renderer
2
+ from devklean.output.json import JsonRenderer
3
+ from devklean.output.text import TextRenderer
4
+
5
+ __all__ = ["JsonRenderer", "Renderer", "TextRenderer"]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, Sequence
4
+
5
+ from devklean.deletion.history import HistoryOperation
6
+ from devklean.models import CleanableItem, DeleteResult
7
+
8
+
9
+ class Renderer(Protocol):
10
+ """Output abstraction for presenting devklean results to the user."""
11
+
12
+ def scan_start(self, root: str) -> None: ...
13
+
14
+ def scan_summary(self, items: list[CleanableItem]) -> int: ...
15
+
16
+ def nothing_to_clean(self) -> None: ...
17
+
18
+ def dry_run_nothing_deleted(self) -> None: ...
19
+
20
+ def dry_run_selected(self, count: int) -> None: ...
21
+
22
+ def aborted(self) -> None: ...
23
+
24
+ def no_items_selected(self) -> None: ...
25
+
26
+ def invalid_directory(self, path: str) -> None: ...
27
+
28
+ def confirm_prompt(self, count: int, total_size: int = 0) -> str: ...
29
+
30
+ def deletion_result(self, result: DeleteResult) -> None: ...
31
+
32
+ def permission_warnings(self, paths: Sequence[str]) -> None: ...
33
+
34
+ def history(
35
+ self,
36
+ operations: Sequence[HistoryOperation],
37
+ invalid_count: int,
38
+ ) -> None: ...
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from typing import TextIO
6
+
7
+ from devklean.output.theme import Palette, get_theme
8
+
9
+ # Semantic symbols — fixed across every command and renderer.
10
+ SYM_SUCCESS = "✓"
11
+ SYM_ERROR = "✗"
12
+ SYM_WARNING = "⚠"
13
+ SYM_INFO = "•"
14
+
15
+ _ROLE_TO_CODE = {
16
+ "success": "green",
17
+ "error": "red",
18
+ "warning": "yellow",
19
+ "info": "cyan",
20
+ "detail": "dim",
21
+ "bold": "bold",
22
+ }
23
+
24
+
25
+ def should_use_color(stream: TextIO, theme_name: str) -> bool:
26
+ """Color is on only when NO_COLOR is unset, stream is a tty, theme != mono."""
27
+ if os.environ.get("NO_COLOR") is not None:
28
+ return False
29
+ if theme_name == "mono":
30
+ return False
31
+ isatty = getattr(stream, "isatty", None)
32
+ return bool(isatty and isatty())
33
+
34
+
35
+ class Console:
36
+ """Centralized, color-gated terminal output with a fixed symbol scheme."""
37
+
38
+ def __init__(
39
+ self,
40
+ stream: TextIO | None = None,
41
+ theme: str = "default",
42
+ color: bool | None = None,
43
+ ) -> None:
44
+ self._stream = stream if stream is not None else sys.stdout
45
+ self._theme = get_theme(theme)
46
+ self._use_color = color if color is not None else should_use_color(self._stream, theme)
47
+
48
+ @property
49
+ def palette(self) -> Palette:
50
+ return self._theme.palette
51
+
52
+ @property
53
+ def use_color(self) -> bool:
54
+ return self._use_color
55
+
56
+ def code(self, role: str) -> str:
57
+ """Return the ANSI code for a semantic role, or '' when color is off."""
58
+ if not self._use_color:
59
+ return ""
60
+ attr = _ROLE_TO_CODE.get(role)
61
+ return getattr(self.palette, attr) if attr else ""
62
+
63
+ @property
64
+ def reset(self) -> str:
65
+ return self.palette.reset if self._use_color else ""
66
+
67
+ def paint(self, text: str, role: str) -> str:
68
+ """Wrap text in the role's color, or return it unchanged when color is off."""
69
+ code = self.code(role)
70
+ if not code:
71
+ return text
72
+ return f"{code}{text}{self.reset}"
73
+
74
+ def _line(self, symbol: str, role: str, message: str) -> None:
75
+ prefix = self.paint(symbol, role)
76
+ print(f"{prefix} {message}", file=self._stream)
77
+
78
+ def success(self, message: str) -> None:
79
+ self._line(SYM_SUCCESS, "success", message)
80
+
81
+ def error(self, message: str) -> None:
82
+ self._line(SYM_ERROR, "error", message)
83
+
84
+ def warning(self, message: str) -> None:
85
+ self._line(SYM_WARNING, "warning", message)
86
+
87
+ def info(self, message: str) -> None:
88
+ self._line(SYM_INFO, "info", message)
89
+
90
+ def detail(self, message: str) -> None:
91
+ print(self.paint(message, "detail"), file=self._stream)
92
+
93
+ def plain(self, message: str = "") -> None:
94
+ print(message, file=self._stream)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+ from devklean.deletion.history import HistoryOperation
6
+ from devklean.formatting import format_size
7
+
8
+ HISTORY_OUTPUT_VERSION = "1.0"
9
+
10
+
11
+ def build_history_payload(operations: Sequence[HistoryOperation]) -> dict:
12
+ """Build the canonical history result structure for renderers and tooling."""
13
+ total_reclaimed_size = sum(op.reclaimed_size for op in operations)
14
+
15
+ return {
16
+ "version": HISTORY_OUTPUT_VERSION,
17
+ "operations": [serialize_history_operation(op) for op in operations],
18
+ "summary": {
19
+ "count": len(operations),
20
+ "total_reclaimed_size": total_reclaimed_size,
21
+ "formatted_total_reclaimed_size": format_size(total_reclaimed_size),
22
+ },
23
+ }
24
+
25
+
26
+ def serialize_history_operation(operation: HistoryOperation) -> dict:
27
+ return {
28
+ "run_id": operation.run_id,
29
+ "timestamp": operation.timestamp,
30
+ "strategy": operation.strategy,
31
+ "item_count": operation.item_count,
32
+ "reclaimed_size": operation.reclaimed_size,
33
+ "formatted_reclaimed_size": format_size(operation.reclaimed_size),
34
+ }
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Sequence
6
+
7
+ from devklean.deletion.history import HistoryOperation
8
+ from devklean.models import CleanableItem, DeleteResult
9
+ from devklean.output.history_payload import build_history_payload
10
+ from devklean.output.scan_payload import (
11
+ build_error_payload,
12
+ build_scan_payload,
13
+ )
14
+
15
+
16
+ class JsonRenderer:
17
+ """Machine-readable JSON output for tooling and automation."""
18
+
19
+ def __init__(self, *, stream=None) -> None:
20
+ self._stream = stream if stream is not None else sys.stdout
21
+ self._root = ""
22
+ self._permission_errors: list[str] = []
23
+
24
+ def scan_start(self, root: str) -> None:
25
+ self._root = root
26
+
27
+ def scan_summary(self, items: list[CleanableItem]) -> int:
28
+ payload = build_scan_payload(self._root, items, self._permission_errors)
29
+ self._emit(payload)
30
+ return payload["summary"]["total_size"]
31
+
32
+ def nothing_to_clean(self) -> None:
33
+ self._emit(build_scan_payload(self._root, [], self._permission_errors))
34
+
35
+ def dry_run_nothing_deleted(self) -> None:
36
+ pass
37
+
38
+ def dry_run_selected(self, count: int) -> None:
39
+ pass
40
+
41
+ def aborted(self) -> None:
42
+ pass
43
+
44
+ def no_items_selected(self) -> None:
45
+ pass
46
+
47
+ def invalid_directory(self, path: str) -> None:
48
+ self._emit(
49
+ build_error_payload(
50
+ "invalid_directory",
51
+ f"'{path}' is not a directory.",
52
+ )
53
+ )
54
+
55
+ def confirm_prompt(self, count: int, total_size: int = 0) -> str:
56
+ raise NotImplementedError("confirm_prompt is not supported in JSON mode")
57
+
58
+ def deletion_result(self, result: DeleteResult) -> None:
59
+ pass
60
+
61
+ def permission_warnings(self, paths) -> None:
62
+ # Captured here and emitted in the scan payload so JSON consumers see
63
+ # the same skipped-path information the text renderer prints.
64
+ self._permission_errors = list(paths)
65
+
66
+ def history(
67
+ self,
68
+ operations: Sequence[HistoryOperation],
69
+ invalid_count: int,
70
+ ) -> None:
71
+ self._emit(build_history_payload(operations))
72
+
73
+ def _emit(self, payload: dict) -> None:
74
+ json.dump(payload, self._stream, indent=2)
75
+ self._stream.write("\n")