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,226 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from devklean.config.models import AppConfig, DefaultsConfig
9
+ from devklean.config.paths import get_config_path
10
+ from devklean.config.targets import merge_targets
11
+
12
+ try:
13
+ import tomllib
14
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
15
+ import tomli as tomllib
16
+
17
+ PROJECT_CONFIG_NAME = ".devklean.toml"
18
+
19
+ _KNOWN_TOP_LEVEL = {"defaults", "targets", "ignore", "exclude"}
20
+ _KNOWN_DEFAULTS = {
21
+ "dry_run",
22
+ "interactive",
23
+ "path",
24
+ "default_yes",
25
+ "theme",
26
+ "confirm_threshold",
27
+ }
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ConfigLoadResult:
32
+ config: AppConfig
33
+ warnings: list[str]
34
+
35
+
36
+ class ConfigManager:
37
+ """Load and merge user configuration with built-in defaults.
38
+
39
+ Precedence (lowest to highest): built-in defaults < global config file <
40
+ project ``.devklean.toml`` discovered by walking up from the start dir.
41
+ Scalar keys take the highest-precedence value; list keys are unioned.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ config_path: Path | None = None,
47
+ project_dir: Path | None = None,
48
+ ) -> None:
49
+ self._config_path = config_path if config_path is not None else get_config_path()
50
+ self._project_dir = project_dir if project_dir is not None else Path.cwd()
51
+
52
+ @property
53
+ def config_path(self) -> Path:
54
+ return self._config_path
55
+
56
+ def load(self) -> AppConfig:
57
+ return self.load_full().config
58
+
59
+ def load_full(self) -> ConfigLoadResult:
60
+ warnings: list[str] = []
61
+
62
+ global_raw = self._read_toml(self._config_path, warnings)
63
+ _validate(global_raw, str(self._config_path), warnings)
64
+
65
+ project_path = self._find_project_config()
66
+ project_raw: dict[str, Any] = {}
67
+ if project_path is not None:
68
+ project_raw = self._read_toml(project_path, warnings)
69
+ _validate(project_raw, str(project_path), warnings)
70
+
71
+ # Layers in increasing precedence.
72
+ config = self._build_config([global_raw, project_raw])
73
+ return ConfigLoadResult(config=config, warnings=warnings)
74
+
75
+ def apply_defaults(self, args, raw_argv: list[str]) -> None:
76
+ """Apply configured defaults when the CLI did not override them."""
77
+ config = getattr(args, "_config", None)
78
+ if config is None:
79
+ return
80
+
81
+ if getattr(args, "command", None) == "clean":
82
+ if "--dry-run" not in raw_argv:
83
+ args.dry_run = config.defaults.dry_run
84
+ if "-i" not in raw_argv and "--interactive" not in raw_argv:
85
+ args.interactive = config.defaults.interactive
86
+
87
+ if getattr(args, "path", None) == "." and not _explicit_path_provided(raw_argv):
88
+ args.path = os.path.expanduser(config.defaults.path)
89
+
90
+ # --- internals ---
91
+
92
+ def _read_toml(self, path: Path, warnings: list[str]) -> dict[str, Any]:
93
+ if not path.is_file():
94
+ return {}
95
+ try:
96
+ return tomllib.loads(path.read_text(encoding="utf-8"))
97
+ except (tomllib.TOMLDecodeError, OSError) as exc:
98
+ warnings.append(f"Could not parse config '{path}': {exc}; ignoring it.")
99
+ return {}
100
+
101
+ def _find_project_config(self) -> Path | None:
102
+ current = self._project_dir.resolve()
103
+ for directory in (current, *current.parents):
104
+ candidate = directory / PROJECT_CONFIG_NAME
105
+ if candidate.is_file():
106
+ return candidate
107
+ return None
108
+
109
+ def _build_config(self, layers: list[dict[str, Any]]) -> AppConfig:
110
+ # Scalars: highest-precedence (last non-empty) layer wins.
111
+ # Lists: unioned across layers preserving order.
112
+ defaults = self._merge_defaults(layers)
113
+
114
+ exclude_dirs = _union_lists(*[_as_str_list(layer.get("exclude", [])) for layer in layers])
115
+
116
+ target_excludes = _union_lists(
117
+ *[_as_str_list(layer.get("targets", {}).get("exclude", [])) for layer in layers]
118
+ )
119
+ custom_targets: dict[str, str] = {}
120
+ for layer in layers:
121
+ custom = layer.get("targets", {}).get("custom", {})
122
+ if isinstance(custom, dict):
123
+ custom_targets.update({str(k): str(v) for k, v in custom.items()})
124
+
125
+ merged_targets = merge_targets(exclude=target_excludes, custom=custom_targets)
126
+
127
+ ignored_paths = tuple(
128
+ _normalize_path(path)
129
+ for path in _union_lists(
130
+ *[_as_str_list(layer.get("ignore", {}).get("paths", [])) for layer in layers]
131
+ )
132
+ )
133
+ ignored_directories = tuple(
134
+ _union_lists(
135
+ exclude_dirs,
136
+ *[_as_str_list(layer.get("ignore", {}).get("directories", [])) for layer in layers],
137
+ )
138
+ )
139
+
140
+ return AppConfig(
141
+ targets=merged_targets,
142
+ ignored_paths=ignored_paths,
143
+ ignored_directories=ignored_directories,
144
+ defaults=defaults,
145
+ )
146
+
147
+ def _merge_defaults(self, layers: list[dict[str, Any]]) -> DefaultsConfig:
148
+ base = DefaultsConfig()
149
+ merged: dict[str, Any] = {
150
+ "dry_run": base.dry_run,
151
+ "interactive": base.interactive,
152
+ "path": base.path,
153
+ "default_yes": base.default_yes,
154
+ "theme": base.theme,
155
+ "confirm_threshold": base.confirm_threshold,
156
+ }
157
+ for layer in layers:
158
+ section = layer.get("defaults", {})
159
+ if not isinstance(section, dict):
160
+ continue
161
+ for key in ("dry_run", "interactive", "default_yes"):
162
+ if key in section:
163
+ merged[key] = bool(section[key])
164
+ if "path" in section:
165
+ merged["path"] = str(section["path"])
166
+ if "theme" in section:
167
+ merged["theme"] = str(section["theme"])
168
+ if "confirm_threshold" in section:
169
+ try:
170
+ merged["confirm_threshold"] = int(section["confirm_threshold"])
171
+ except (TypeError, ValueError):
172
+ pass
173
+ return DefaultsConfig(**merged)
174
+
175
+
176
+ def _validate(data: dict[str, Any], source: str, warnings: list[str]) -> None:
177
+ for key in data:
178
+ if key not in _KNOWN_TOP_LEVEL:
179
+ warnings.append(f"Unknown config key '{key}' in '{source}' (ignored).")
180
+ defaults_section = data.get("defaults", {})
181
+ if isinstance(defaults_section, dict):
182
+ for key in defaults_section:
183
+ if key not in _KNOWN_DEFAULTS:
184
+ warnings.append(f"Unknown key 'defaults.{key}' in '{source}' (ignored).")
185
+
186
+
187
+ def _union_lists(*lists: list[str]) -> list[str]:
188
+ seen: dict[str, None] = {}
189
+ for items in lists:
190
+ for item in items:
191
+ seen.setdefault(item, None)
192
+ return list(seen)
193
+
194
+
195
+ def _as_str_list(value: Any) -> list[str]:
196
+ if not isinstance(value, list):
197
+ return []
198
+ return [str(item) for item in value]
199
+
200
+
201
+ def _normalize_path(path: str) -> str:
202
+ return os.path.abspath(os.path.expanduser(path))
203
+
204
+
205
+ def _explicit_path_provided(argv: list[str]) -> bool:
206
+ """Return True when a positional path appears on the command line."""
207
+ command_names = {
208
+ "scan",
209
+ "clean",
210
+ "history",
211
+ "doctor",
212
+ "stats",
213
+ "restore",
214
+ "config",
215
+ "plugins",
216
+ }
217
+ index = 1
218
+ if index < len(argv) and argv[index] in command_names:
219
+ index += 1
220
+
221
+ while index < len(argv):
222
+ if argv[index].startswith("-"):
223
+ index += 1
224
+ continue
225
+ return True
226
+ return False
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from devklean.config.defaults import DEFAULT_TARGETS
6
+
7
+ # Single source of truth for the large-deletion confirmation threshold; the
8
+ # CLI/TUI confirmation flow re-exports this as DEFAULT_LARGE_THRESHOLD.
9
+ DEFAULT_CONFIRM_THRESHOLD = 1024**3 # 1 GiB
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class DefaultsConfig:
14
+ dry_run: bool = False
15
+ interactive: bool = False
16
+ path: str = "."
17
+ default_yes: bool = False
18
+ theme: str = "default"
19
+ confirm_threshold: int = DEFAULT_CONFIRM_THRESHOLD
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AppConfig:
24
+ targets: dict[str, str]
25
+ ignored_paths: tuple[str, ...] = ()
26
+ ignored_directories: tuple[str, ...] = ()
27
+ defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
28
+
29
+ @property
30
+ def scan_settings(self) -> ScanSettings:
31
+ return ScanSettings(
32
+ targets=dict(self.targets),
33
+ ignored_paths=frozenset(self.ignored_paths),
34
+ ignored_directories=frozenset(self.ignored_directories),
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ScanSettings:
40
+ """Scan-specific settings derived from application configuration."""
41
+
42
+ targets: dict[str, str]
43
+ ignored_paths: frozenset[str] = field(default_factory=frozenset)
44
+ ignored_directories: frozenset[str] = field(default_factory=frozenset)
45
+
46
+ @classmethod
47
+ def defaults(cls) -> ScanSettings:
48
+ return cls(targets=dict(DEFAULT_TARGETS))
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def get_config_path() -> Path:
9
+ """Return the platform-appropriate config file path.
10
+
11
+ An explicit ``XDG_CONFIG_HOME`` override is honored on every platform; the
12
+ Windows-native ``APPDATA`` location is only the fallback when it is unset.
13
+ """
14
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
15
+ if xdg_config:
16
+ return Path(xdg_config) / "devklean" / "config.toml"
17
+
18
+ if sys.platform == "win32":
19
+ base = os.environ.get("APPDATA")
20
+ if base:
21
+ return Path(base) / "devklean" / "config.toml"
22
+ return Path.home() / "AppData" / "Roaming" / "devklean" / "config.toml"
23
+
24
+ return Path.home() / ".config" / "devklean" / "config.toml"
@@ -0,0 +1,23 @@
1
+ """Target merge logic for configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devklean.config.defaults import DEFAULT_TARGETS
6
+
7
+
8
+ def merge_targets(
9
+ *,
10
+ exclude: list[str] | tuple[str, ...] = (),
11
+ custom: dict[str, str] | None = None,
12
+ ) -> dict[str, str]:
13
+ """Merge built-in defaults with user exclusions and custom targets."""
14
+ merged = dict(DEFAULT_TARGETS)
15
+
16
+ for name in exclude:
17
+ merged.pop(name, None)
18
+
19
+ if custom:
20
+ for name, label in custom.items():
21
+ merged[str(name)] = str(label)
22
+
23
+ return merged
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from devklean.deletion.integrity import IntegrityReport, check_integrity
4
+ from devklean.deletion.metadata import MetadataManager
5
+ from devklean.deletion.paths import get_deletion_metadata_dir
6
+ from devklean.deletion.safety import SafetyValidator, SafetyViolation
7
+ from devklean.deletion.trash import delete_items
8
+
9
+ __all__ = [
10
+ "IntegrityReport",
11
+ "MetadataManager",
12
+ "SafetyValidator",
13
+ "SafetyViolation",
14
+ "check_integrity",
15
+ "delete_items",
16
+ "get_deletion_metadata_dir",
17
+ ]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Sequence
5
+
6
+ from devklean.deletion.metadata import StoredDeletionMetadata
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class HistoryOperation:
11
+ """A single cleanup operation aggregated from per-item metadata records."""
12
+
13
+ run_id: str | None
14
+ timestamp: str
15
+ strategy: str
16
+ item_count: int
17
+ reclaimed_size: int
18
+
19
+
20
+ def build_history(
21
+ records: Sequence[StoredDeletionMetadata],
22
+ ) -> tuple[HistoryOperation, ...]:
23
+ """Aggregate per-item deletion records into cleanup operations.
24
+
25
+ Records sharing a ``run_id`` are grouped into one operation. Legacy
26
+ records without a ``run_id`` each become their own single-item operation.
27
+ Operations are returned newest-first by timestamp.
28
+ """
29
+ grouped: dict[str, list[StoredDeletionMetadata]] = {}
30
+ ungrouped: list[StoredDeletionMetadata] = []
31
+
32
+ for stored in records:
33
+ run_id = stored.record.run_id
34
+ if run_id is None:
35
+ ungrouped.append(stored)
36
+ else:
37
+ grouped.setdefault(run_id, []).append(stored)
38
+
39
+ operations: list[HistoryOperation] = []
40
+
41
+ for run_id, group in grouped.items():
42
+ first = group[0].record
43
+ operations.append(
44
+ HistoryOperation(
45
+ run_id=run_id,
46
+ timestamp=first.timestamp,
47
+ strategy=first.strategy,
48
+ item_count=len(group),
49
+ reclaimed_size=sum(entry.record.item.size for entry in group),
50
+ )
51
+ )
52
+
53
+ for stored in ungrouped:
54
+ record = stored.record
55
+ operations.append(
56
+ HistoryOperation(
57
+ run_id=None,
58
+ timestamp=record.timestamp,
59
+ strategy=record.strategy,
60
+ item_count=1,
61
+ reclaimed_size=record.item.size,
62
+ )
63
+ )
64
+
65
+ operations.sort(key=lambda op: op.timestamp, reverse=True)
66
+ return tuple(operations)
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from devklean.deletion.metadata import (
6
+ CorruptMetadata,
7
+ MetadataManager,
8
+ StoredDeletionMetadata,
9
+ )
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class IntegrityReport:
14
+ """The outcome of inspecting the metadata store."""
15
+
16
+ valid: tuple[StoredDeletionMetadata, ...]
17
+ corrupt: tuple[CorruptMetadata, ...]
18
+
19
+ @property
20
+ def has_issues(self) -> bool:
21
+ return bool(self.corrupt)
22
+
23
+ @property
24
+ def healthy(self) -> bool:
25
+ return not self.has_issues
26
+
27
+
28
+ def check_integrity(manager: MetadataManager) -> IntegrityReport:
29
+ """Inspect the metadata store for corruption.
30
+
31
+ Detects malformed JSON, missing fields, and type mismatches on load.
32
+
33
+ Orphan detection (records whose trashed item was later emptied from the
34
+ trash) is no longer possible: items go to the native OS trash via
35
+ ``send2trash``, which devklean neither owns nor tracks, so there is no trash
36
+ path to check for existence.
37
+ """
38
+ snapshot = manager.load_records()
39
+ return IntegrityReport(valid=snapshot.records, corrupt=snapshot.corrupt)
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Sequence
8
+ from uuid import uuid4
9
+
10
+ from devklean.deletion.paths import get_deletion_metadata_dir
11
+ from devklean.models import CleanableItem, DeleteResult
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DeletionMetadataItem:
16
+ original_path: str
17
+ display_name: str
18
+ size: int
19
+
20
+ def to_dict(self) -> dict[str, object]:
21
+ return {
22
+ "original_path": self.original_path,
23
+ "display_name": self.display_name,
24
+ "size": self.size,
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DeletionMetadataRecord:
30
+ schema_version: int
31
+ deletion_id: str
32
+ run_id: str | None
33
+ timestamp: str
34
+ strategy: str
35
+ item: DeletionMetadataItem
36
+
37
+ def to_dict(self) -> dict[str, object]:
38
+ return {
39
+ "schema_version": self.schema_version,
40
+ "deletion": {
41
+ "id": self.deletion_id,
42
+ "run_id": self.run_id,
43
+ "timestamp": self.timestamp,
44
+ "strategy": self.strategy,
45
+ },
46
+ "item": self.item.to_dict(),
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class StoredDeletionMetadata:
52
+ path: Path
53
+ record: DeletionMetadataRecord
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class CorruptMetadata:
58
+ """A metadata file that could not be parsed, with the reason why."""
59
+
60
+ path: Path
61
+ reason: str
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class MetadataLoadResult:
66
+ records: tuple[StoredDeletionMetadata, ...]
67
+ corrupt: tuple[CorruptMetadata, ...]
68
+
69
+ @property
70
+ def invalid_count(self) -> int:
71
+ return len(self.corrupt)
72
+
73
+
74
+ def _parse_record(data: dict[str, object]) -> DeletionMetadataRecord:
75
+ deletion = data.get("deletion")
76
+ item = data.get("item")
77
+ if not isinstance(deletion, dict) or not isinstance(item, dict):
78
+ raise ValueError("missing or invalid 'deletion'/'item' section")
79
+
80
+ deletion_id = deletion.get("id")
81
+ run_id = deletion.get("run_id")
82
+ timestamp = deletion.get("timestamp")
83
+ strategy = deletion.get("strategy")
84
+ original_path = item.get("original_path")
85
+ display_name = item.get("display_name")
86
+ size = item.get("size")
87
+
88
+ # Records predating the schema_version field are treated as v1. Any integer
89
+ # version is accepted as-is; there are no migrations yet.
90
+ schema_version = data.get("schema_version", 1)
91
+
92
+ if not (
93
+ isinstance(deletion_id, str)
94
+ and (run_id is None or isinstance(run_id, str))
95
+ and isinstance(timestamp, str)
96
+ and isinstance(strategy, str)
97
+ and isinstance(original_path, str)
98
+ and isinstance(display_name, str)
99
+ and isinstance(size, int)
100
+ and isinstance(schema_version, int)
101
+ ):
102
+ raise ValueError("missing or wrong-typed metadata fields")
103
+
104
+ return DeletionMetadataRecord(
105
+ schema_version=schema_version,
106
+ deletion_id=deletion_id,
107
+ run_id=run_id,
108
+ timestamp=timestamp,
109
+ strategy=strategy,
110
+ item=DeletionMetadataItem(
111
+ original_path=original_path,
112
+ display_name=display_name,
113
+ size=size,
114
+ ),
115
+ )
116
+
117
+
118
+ def _metadata_sort_key(entry: StoredDeletionMetadata) -> str:
119
+ return entry.record.timestamp
120
+
121
+
122
+ class MetadataManager:
123
+ """Persist deletion metadata in the app data directory."""
124
+
125
+ def __init__(self, storage_dir: Path | None = None) -> None:
126
+ self._storage_dir = storage_dir if storage_dir is not None else get_deletion_metadata_dir()
127
+
128
+ @property
129
+ def storage_dir(self) -> Path:
130
+ return self._storage_dir
131
+
132
+ def load_records(self) -> MetadataLoadResult:
133
+ if not self._storage_dir.exists():
134
+ return MetadataLoadResult(records=(), corrupt=())
135
+
136
+ records: list[StoredDeletionMetadata] = []
137
+ corrupt: list[CorruptMetadata] = []
138
+
139
+ for path in sorted(self._storage_dir.glob("*.json")):
140
+ try:
141
+ data = json.loads(path.read_text(encoding="utf-8"))
142
+ if not isinstance(data, dict):
143
+ raise ValueError("metadata file is not a JSON object")
144
+ record = _parse_record(data)
145
+ except json.JSONDecodeError:
146
+ corrupt.append(CorruptMetadata(path=path, reason="malformed JSON"))
147
+ continue
148
+ except OSError as exc:
149
+ corrupt.append(CorruptMetadata(path=path, reason=f"unreadable file: {exc}"))
150
+ continue
151
+ except (ValueError, TypeError) as exc:
152
+ corrupt.append(CorruptMetadata(path=path, reason=str(exc)))
153
+ continue
154
+
155
+ records.append(StoredDeletionMetadata(path=path, record=record))
156
+
157
+ records.sort(key=_metadata_sort_key, reverse=True)
158
+ return MetadataLoadResult(records=tuple(records), corrupt=tuple(corrupt))
159
+
160
+ def remove_record(self, entry: StoredDeletionMetadata | CorruptMetadata) -> None:
161
+ """Delete a single metadata file from the store (valid or corrupt)."""
162
+ entry.path.unlink(missing_ok=True)
163
+
164
+ def record_successes(
165
+ self,
166
+ items: Sequence[CleanableItem],
167
+ result: DeleteResult,
168
+ strategy: str,
169
+ ) -> None:
170
+ deleted_paths = set(result.deleted)
171
+ if not deleted_paths:
172
+ return
173
+
174
+ self._storage_dir.mkdir(parents=True, exist_ok=True)
175
+
176
+ run_id = uuid4().hex
177
+ timestamp = datetime.now(timezone.utc).isoformat()
178
+
179
+ for item in items:
180
+ if item.path not in deleted_paths:
181
+ continue
182
+
183
+ record = DeletionMetadataRecord(
184
+ schema_version=3,
185
+ deletion_id=uuid4().hex,
186
+ run_id=run_id,
187
+ timestamp=timestamp,
188
+ strategy=strategy,
189
+ item=DeletionMetadataItem(
190
+ original_path=item.path,
191
+ display_name=item.display_label,
192
+ size=item.size,
193
+ ),
194
+ )
195
+ stamp = record.timestamp.replace(":", "").replace("+00:00", "Z")
196
+ filename = f"{stamp}_{record.deletion_id}.json"
197
+ path = self._storage_dir / filename
198
+ path.write_text(json.dumps(record.to_dict(), indent=2) + "\n", encoding="utf-8")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def get_deletion_metadata_dir() -> Path:
9
+ """Return the directory used to persist deletion metadata.
10
+
11
+ An explicit ``XDG_DATA_HOME`` override is honored on every platform; the
12
+ Windows-native ``APPDATA`` location is only the fallback when it is unset.
13
+ """
14
+ xdg_data = os.environ.get("XDG_DATA_HOME")
15
+ if xdg_data:
16
+ return Path(xdg_data) / "devklean" / "deletions"
17
+
18
+ if sys.platform == "win32":
19
+ base = os.environ.get("APPDATA")
20
+ if base:
21
+ return Path(base) / "devklean" / "deletions"
22
+ return Path.home() / "AppData" / "Roaming" / "devklean" / "deletions"
23
+
24
+ return Path.home() / ".local" / "share" / "devklean" / "deletions"