clutterxp 1.1.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 (84) hide show
  1. clutterxp/__init__.py +4 -0
  2. clutterxp/cli/__init__.py +7 -0
  3. clutterxp/cli/app.py +251 -0
  4. clutterxp/cli/cmd_config.py +250 -0
  5. clutterxp/cli/cmd_doctor.py +449 -0
  6. clutterxp/cli/cmd_duplicates.py +253 -0
  7. clutterxp/cli/cmd_gui.py +22 -0
  8. clutterxp/cli/cmd_history.py +233 -0
  9. clutterxp/cli/cmd_init.py +76 -0
  10. clutterxp/cli/cmd_organize.py +226 -0
  11. clutterxp/cli/cmd_rules.py +540 -0
  12. clutterxp/cli/cmd_scan.py +109 -0
  13. clutterxp/cli/cmd_service.py +130 -0
  14. clutterxp/cli/cmd_undo.py +180 -0
  15. clutterxp/cli/cmd_watch.py +147 -0
  16. clutterxp/cli/errors.py +166 -0
  17. clutterxp/cli/render.py +390 -0
  18. clutterxp/config/__init__.py +40 -0
  19. clutterxp/config/jsonschema.py +12 -0
  20. clutterxp/config/loader.py +212 -0
  21. clutterxp/config/placeholders.py +79 -0
  22. clutterxp/config/runtime.py +30 -0
  23. clutterxp/config/schema.py +166 -0
  24. clutterxp/config/validate.py +226 -0
  25. clutterxp/config/writer.py +248 -0
  26. clutterxp/config.example.yaml +76 -0
  27. clutterxp/duplicates/__init__.py +22 -0
  28. clutterxp/duplicates/detector.py +438 -0
  29. clutterxp/duplicates/hashcache.py +149 -0
  30. clutterxp/duplicates/namenorm.py +45 -0
  31. clutterxp/duplicates/quarantine.py +196 -0
  32. clutterxp/duplicates/report.py +110 -0
  33. clutterxp/gui/__init__.py +1 -0
  34. clutterxp/gui/api.py +494 -0
  35. clutterxp/gui/app.py +205 -0
  36. clutterxp/gui/models_qt.py +544 -0
  37. clutterxp/gui/views/__init__.py +21 -0
  38. clutterxp/gui/views/dashboard.py +253 -0
  39. clutterxp/gui/views/duplicates.py +404 -0
  40. clutterxp/gui/views/folders.py +354 -0
  41. clutterxp/gui/views/history.py +281 -0
  42. clutterxp/gui/views/preview.py +277 -0
  43. clutterxp/gui/views/rules.py +527 -0
  44. clutterxp/gui/views/settings.py +172 -0
  45. clutterxp/gui/workers.py +147 -0
  46. clutterxp/history/__init__.py +23 -0
  47. clutterxp/history/db.py +174 -0
  48. clutterxp/history/pathcodec.py +42 -0
  49. clutterxp/history/recovery.py +318 -0
  50. clutterxp/history/schema.sql +43 -0
  51. clutterxp/history/service.py +493 -0
  52. clutterxp/history/undo.py +373 -0
  53. clutterxp/interfaces.py +164 -0
  54. clutterxp/locking.py +113 -0
  55. clutterxp/logging_setup.py +73 -0
  56. clutterxp/models.py +266 -0
  57. clutterxp/mover/__init__.py +26 -0
  58. clutterxp/mover/collision.py +46 -0
  59. clutterxp/mover/fsops.py +191 -0
  60. clutterxp/mover/safe_move.py +741 -0
  61. clutterxp/organizer.py +388 -0
  62. clutterxp/packaging/clutterxp.service +15 -0
  63. clutterxp/paths.py +112 -0
  64. clutterxp/planner.py +339 -0
  65. clutterxp/py.typed +0 -0
  66. clutterxp/rules/__init__.py +16 -0
  67. clutterxp/rules/engine.py +374 -0
  68. clutterxp/rules/matcher.py +222 -0
  69. clutterxp/rules/template.py +171 -0
  70. clutterxp/rules/tokenizer.py +118 -0
  71. clutterxp/safety.py +111 -0
  72. clutterxp/scanner.py +196 -0
  73. clutterxp/service_install.py +208 -0
  74. clutterxp/units.py +59 -0
  75. clutterxp/watch/__init__.py +37 -0
  76. clutterxp/watch/debounce.py +84 -0
  77. clutterxp/watch/events.py +259 -0
  78. clutterxp/watch/ignore.py +77 -0
  79. clutterxp/watch/service.py +662 -0
  80. clutterxp/watch/settle.py +170 -0
  81. clutterxp-1.1.0.dist-info/METADATA +149 -0
  82. clutterxp-1.1.0.dist-info/RECORD +84 -0
  83. clutterxp-1.1.0.dist-info/WHEEL +4 -0
  84. clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
@@ -0,0 +1,449 @@
1
+ """Command handler for `clutterxp doctor` diagnostics and recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import sqlite3
8
+ import time
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Annotated, Any
11
+
12
+ import typer
13
+
14
+ from clutterxp import paths
15
+ from clutterxp.cli.errors import handle_errors
16
+ from clutterxp.cli.render import print_json, render_doctor_results, safe_str
17
+ from clutterxp.config import loader
18
+ from clutterxp.history.db import integrity_check
19
+ from clutterxp.history.recovery import reconcile
20
+ from clutterxp.history.service import HistoryService
21
+
22
+ if TYPE_CHECKING:
23
+ from clutterxp.cli.app import GlobalOptions
24
+
25
+
26
+ def doctor_command(
27
+ ctx: typer.Context,
28
+ fix: Annotated[
29
+ bool,
30
+ typer.Option(
31
+ "--fix",
32
+ help="Automatically reconcile pending ops, clean partial files, and fix permissions",
33
+ ),
34
+ ] = False,
35
+ ) -> None:
36
+ """Run diagnostics on configuration, database integrity, locks, and filesystem health."""
37
+ from clutterxp.cli.app import get_globals
38
+
39
+ opts: GlobalOptions = get_globals(ctx)
40
+ checks: list[dict[str, Any]] = []
41
+ fixes: list[dict[str, Any]] = []
42
+
43
+ with handle_errors(json_mode=opts.json_mode, command_name="doctor"):
44
+ # 1. Root check
45
+ if hasattr(os, "geteuid") and os.geteuid() == 0 and not opts.allow_root:
46
+ checks.append(
47
+ {
48
+ "name": "running_as_root",
49
+ "status": "error",
50
+ "message": (
51
+ "Running as root is forbidden for safety. Pass --allow-root to override."
52
+ ),
53
+ }
54
+ )
55
+ else:
56
+ checks.append(
57
+ {
58
+ "name": "running_as_root",
59
+ "status": "ok",
60
+ "message": "Running as standard unprivileged user.",
61
+ }
62
+ )
63
+
64
+ # 2. Config validity
65
+ cfg_path = opts.config if opts.config is not None else paths.config_path()
66
+ cfg_valid = False
67
+ loaded_cfg = None
68
+ if not cfg_path.exists():
69
+ checks.append(
70
+ {
71
+ "name": "config_validity",
72
+ "status": "error",
73
+ "message": f"Config file not found at {safe_str(cfg_path)}",
74
+ }
75
+ )
76
+ else:
77
+ try:
78
+ loaded_cfg = loader.load(cfg_path)
79
+ if loaded_cfg.is_valid:
80
+ cfg_valid = True
81
+ checks.append(
82
+ {
83
+ "name": "config_validity",
84
+ "status": "ok",
85
+ "message": f"Config valid ({len(loaded_cfg.rules)} active rules)",
86
+ }
87
+ )
88
+ else:
89
+ checks.append(
90
+ {
91
+ "name": "config_validity",
92
+ "status": "error",
93
+ "message": f"Config errors: {', '.join(loaded_cfg.errors)}",
94
+ }
95
+ )
96
+ except Exception as e:
97
+ checks.append(
98
+ {
99
+ "name": "config_validity",
100
+ "status": "error",
101
+ "message": f"Failed to load config: {e}",
102
+ }
103
+ )
104
+
105
+ # 3. Database integrity
106
+ db_path = paths.history_db_path()
107
+ if not db_path.exists():
108
+ checks.append(
109
+ {
110
+ "name": "database_integrity",
111
+ "status": "ok",
112
+ "message": "History database does not exist yet (clean state).",
113
+ }
114
+ )
115
+ else:
116
+ try:
117
+ conn = sqlite3.connect(db_path)
118
+ is_ok = integrity_check(conn)
119
+ if is_ok:
120
+ with contextlib.suppress(sqlite3.Error):
121
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
122
+ checks.append(
123
+ {
124
+ "name": "database_integrity",
125
+ "status": "ok",
126
+ "message": "Database passed PRAGMA integrity_check.",
127
+ }
128
+ )
129
+ else:
130
+ checks.append(
131
+ {
132
+ "name": "database_integrity",
133
+ "status": "error",
134
+ "message": "Database failed PRAGMA integrity_check.",
135
+ }
136
+ )
137
+ conn.close()
138
+ except Exception as e:
139
+ checks.append(
140
+ {
141
+ "name": "database_integrity",
142
+ "status": "error",
143
+ "message": f"Database check exception: {e}",
144
+ }
145
+ )
146
+
147
+ # 4. Pending operations & reconciliation
148
+ if db_path.exists():
149
+ try:
150
+ history = HistoryService(db_path)
151
+ pending = history.pending_ops()
152
+ if pending:
153
+ if fix:
154
+ actions = reconcile(history)
155
+ fixes.append(
156
+ {
157
+ "check": "pending_operations",
158
+ "action": "reconciled",
159
+ "message": f"Reconciled {len(actions)} pending operations.",
160
+ }
161
+ )
162
+ checks.append(
163
+ {
164
+ "name": "pending_operations",
165
+ "status": "ok",
166
+ "message": f"Reconciled {len(pending)} pending operations.",
167
+ }
168
+ )
169
+ else:
170
+ checks.append(
171
+ {
172
+ "name": "pending_operations",
173
+ "status": "error",
174
+ "message": (
175
+ f"Found {len(pending)} un-reconciled pending operations. "
176
+ "Run with --fix to recover."
177
+ ),
178
+ }
179
+ )
180
+ else:
181
+ checks.append(
182
+ {
183
+ "name": "pending_operations",
184
+ "status": "ok",
185
+ "message": "No pending operations in history journal.",
186
+ }
187
+ )
188
+ except Exception as e:
189
+ checks.append(
190
+ {
191
+ "name": "pending_operations",
192
+ "status": "error",
193
+ "message": f"Failed to check pending operations: {e}",
194
+ }
195
+ )
196
+ else:
197
+ checks.append(
198
+ {
199
+ "name": "pending_operations",
200
+ "status": "ok",
201
+ "message": "No history database present.",
202
+ }
203
+ )
204
+
205
+ # 5. Stale partial files (*.clutterxp-partial older than 1h)
206
+ dirs_to_check: set[Path] = set()
207
+ if loaded_cfg:
208
+ for w in loaded_cfg.watch_entries:
209
+ with contextlib.suppress(Exception):
210
+ dirs_to_check.add(paths.expand(w.path))
211
+ for r in loaded_cfg.all_rules:
212
+ with contextlib.suppress(Exception):
213
+ expanded_dest = paths.expand(r.destination)
214
+ dirs_to_check.add(expanded_dest)
215
+
216
+ stale_files: list[Path] = []
217
+ now_ts = time.time()
218
+ for d in dirs_to_check:
219
+ if d.exists() and d.is_dir():
220
+ for p in d.glob("**/*.clutterxp-partial"):
221
+ try:
222
+ if now_ts - p.stat().st_mtime > 3600:
223
+ stale_files.append(p)
224
+ except OSError:
225
+ pass
226
+
227
+ if stale_files:
228
+ if fix:
229
+ removed_count = 0
230
+ for sf in stale_files:
231
+ try:
232
+ sf.unlink()
233
+ removed_count += 1
234
+ except OSError:
235
+ pass
236
+ fixes.append(
237
+ {
238
+ "check": "stale_partial_files",
239
+ "action": "cleaned",
240
+ "message": (
241
+ f"Removed {removed_count} stale partial files older than 1 hour."
242
+ ),
243
+ }
244
+ )
245
+ checks.append(
246
+ {
247
+ "name": "stale_partial_files",
248
+ "status": "ok",
249
+ "message": f"Cleaned {removed_count} stale partial files.",
250
+ }
251
+ )
252
+ else:
253
+ checks.append(
254
+ {
255
+ "name": "stale_partial_files",
256
+ "status": "error",
257
+ "message": (
258
+ f"Found {len(stale_files)} stale partial files older than 1 hour. "
259
+ "Run with --fix to clean."
260
+ ),
261
+ }
262
+ )
263
+ else:
264
+ checks.append(
265
+ {
266
+ "name": "stale_partial_files",
267
+ "status": "ok",
268
+ "message": "No stale partial files found.",
269
+ }
270
+ )
271
+
272
+ # 6. Destination reachability and writability
273
+ if loaded_cfg and cfg_valid:
274
+ unwritable: list[str] = []
275
+ for r in loaded_cfg.rules:
276
+ try:
277
+ exp_dest = paths.expand(r.destination)
278
+ curr: Path | None = exp_dest
279
+ while curr and not curr.exists() and curr != curr.parent:
280
+ curr = curr.parent
281
+ if curr and curr.exists():
282
+ if not os.access(curr, os.W_OK):
283
+ unwritable.append(f"{r.name} -> {curr} not writable")
284
+ else:
285
+ unwritable.append(f"{r.name} -> {exp_dest} unreachable")
286
+ except Exception as e:
287
+ unwritable.append(f"{r.name} -> {e}")
288
+
289
+ if unwritable:
290
+ checks.append(
291
+ {
292
+ "name": "destination_permissions",
293
+ "status": "error",
294
+ "message": f"Destination access issues: {'; '.join(unwritable)}",
295
+ }
296
+ )
297
+ else:
298
+ checks.append(
299
+ {
300
+ "name": "destination_permissions",
301
+ "status": "ok",
302
+ "message": "All rule destinations are reachable and writable.",
303
+ }
304
+ )
305
+
306
+ # 7. Filesystem boundaries
307
+ if loaded_cfg and cfg_valid and loaded_cfg.watch_entries and loaded_cfg.rules:
308
+ cross_dev = False
309
+ for w in loaded_cfg.watch_entries:
310
+ with contextlib.suppress(Exception):
311
+ w_path = paths.expand(w.path)
312
+ if w_path.exists():
313
+ w_dev = w_path.stat().st_dev
314
+ for r in loaded_cfg.rules:
315
+ r_dest = paths.expand(r.destination)
316
+ curr = r_dest
317
+ while curr and not curr.exists() and curr != curr.parent:
318
+ curr = curr.parent
319
+ if curr and curr.exists() and curr.stat().st_dev != w_dev:
320
+ cross_dev = True
321
+ break
322
+ checks.append(
323
+ {
324
+ "name": "filesystem_boundaries",
325
+ "status": "ok",
326
+ "message": (
327
+ "Cross-filesystem boundaries detected (will use safe copy-verify)."
328
+ if cross_dev
329
+ else "All paths share the same filesystem (fast rename available)."
330
+ ),
331
+ }
332
+ )
333
+
334
+ # 8. Inotify limits
335
+ inotify_path = Path("/proc/sys/fs/inotify/max_user_watches")
336
+ if inotify_path.exists():
337
+ try:
338
+ val = int(inotify_path.read_text().strip())
339
+ if val < 8192:
340
+ checks.append(
341
+ {
342
+ "name": "inotify_limits",
343
+ "status": "warning",
344
+ "message": f"max_user_watches is low ({val}). Recommended >= 524288.",
345
+ }
346
+ )
347
+ else:
348
+ checks.append(
349
+ {
350
+ "name": "inotify_limits",
351
+ "status": "ok",
352
+ "message": f"max_user_watches is adequate ({val}).",
353
+ }
354
+ )
355
+ except Exception:
356
+ checks.append(
357
+ {
358
+ "name": "inotify_limits",
359
+ "status": "ok",
360
+ "message": "Could not parse inotify max_user_watches.",
361
+ }
362
+ )
363
+
364
+ # 9. Lock files check
365
+ fs_lock = paths.fs_lock_path()
366
+ watch_lock = paths.watch_lock_path()
367
+ checks.append(
368
+ {
369
+ "name": "lock_files",
370
+ "status": "ok",
371
+ "message": f"Lock paths: {safe_str(fs_lock)}, {safe_str(watch_lock)}",
372
+ }
373
+ )
374
+
375
+ # 10. File permissions on state directory and log files
376
+ perm_issues = False
377
+ state_d = paths.state_dir()
378
+ if state_d.exists():
379
+ mode = state_d.stat().st_mode & 0o777
380
+ if mode != 0o700:
381
+ if fix:
382
+ state_d.chmod(0o700)
383
+ fixes.append(
384
+ {
385
+ "check": "state_permissions",
386
+ "action": "chmod 0700",
387
+ "message": f"Corrected permissions on {safe_str(state_d)}",
388
+ }
389
+ )
390
+ else:
391
+ perm_issues = True
392
+
393
+ log_p = paths.log_path()
394
+ if log_p.exists():
395
+ mode = log_p.stat().st_mode & 0o777
396
+ if mode != 0o600:
397
+ if fix:
398
+ log_p.chmod(0o600)
399
+ fixes.append(
400
+ {
401
+ "check": "log_permissions",
402
+ "action": "chmod 0600",
403
+ "message": f"Corrected permissions on {safe_str(log_p)}",
404
+ }
405
+ )
406
+ else:
407
+ perm_issues = True
408
+
409
+ if perm_issues:
410
+ checks.append(
411
+ {
412
+ "name": "state_permissions",
413
+ "status": "error",
414
+ "message": (
415
+ "State files or directory have insecure permissions (expected 0700/0600). "
416
+ "Run with --fix."
417
+ ),
418
+ }
419
+ )
420
+ else:
421
+ checks.append(
422
+ {
423
+ "name": "state_permissions",
424
+ "status": "ok",
425
+ "message": "State directory and files have secure permissions (0700/0600).",
426
+ }
427
+ )
428
+
429
+ # Overall diagnosis
430
+ has_error = any(c["status"] == "error" for c in checks)
431
+ all_ok = not has_error
432
+
433
+ if opts.json_mode:
434
+ print_json(
435
+ {
436
+ "schema_version": 1,
437
+ "ok": all_ok,
438
+ "checks": checks,
439
+ "fixes": fixes,
440
+ }
441
+ )
442
+ else:
443
+ render_doctor_results(checks, fixes, no_color=opts.no_color)
444
+
445
+ if has_error:
446
+ raise typer.Exit(code=3)
447
+
448
+
449
+ __all__ = ["doctor_command"]
@@ -0,0 +1,253 @@
1
+ """Command handlers for `clutterxp duplicates` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import os
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Annotated
12
+
13
+ import typer
14
+ from rich.table import Table
15
+
16
+ from clutterxp import paths
17
+ from clutterxp.cli.errors import handle_errors
18
+ from clutterxp.cli.render import get_console, print_json, safe_str
19
+ from clutterxp.duplicates.detector import DuplicateDetectorImpl
20
+ from clutterxp.duplicates.hashcache import HashCache
21
+ from clutterxp.duplicates.report import groups_to_csv_rows, groups_to_json
22
+ from clutterxp.units import parse_size
23
+
24
+ if TYPE_CHECKING:
25
+ from clutterxp.cli.app import GlobalOptions
26
+
27
+ duplicates_app = typer.Typer(
28
+ help="Detect duplicate files across directories",
29
+ no_args_is_help=True,
30
+ )
31
+
32
+
33
+ @duplicates_app.command("scan")
34
+ def duplicates_scan_command(
35
+ ctx: typer.Context,
36
+ paths_arg: Annotated[
37
+ list[Path],
38
+ typer.Argument(
39
+ metavar="PATH...",
40
+ help="One or more directory paths to scan for duplicates",
41
+ ),
42
+ ],
43
+ recursive: Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--recursive/--no-recursive",
47
+ help="Scan directories recursively",
48
+ ),
49
+ ] = True,
50
+ min_size: Annotated[
51
+ str,
52
+ typer.Option(
53
+ "--min-size",
54
+ help="Minimum file size to consider (e.g. 1B, 10KB, 1MB)",
55
+ ),
56
+ ] = "1B",
57
+ exclude: Annotated[
58
+ list[str] | None,
59
+ typer.Option(
60
+ "--exclude",
61
+ help="Glob pattern to exclude from scan (repeatable)",
62
+ ),
63
+ ] = None,
64
+ jobs: Annotated[
65
+ int,
66
+ typer.Option(
67
+ "--jobs",
68
+ "-j",
69
+ help="Number of parallel hashing threads",
70
+ ),
71
+ ] = min(4, os.cpu_count() or 1),
72
+ no_potential: Annotated[
73
+ bool,
74
+ typer.Option(
75
+ "--no-potential",
76
+ help="Skip potential duplicates stage based on name normalization",
77
+ ),
78
+ ] = False,
79
+ output_format: Annotated[
80
+ str,
81
+ typer.Option(
82
+ "--format",
83
+ help="Output format: table | json | csv",
84
+ ),
85
+ ] = "table",
86
+ output_file: Annotated[
87
+ Path | None,
88
+ typer.Option(
89
+ "--output",
90
+ "-o",
91
+ help="Write results to file instead of stdout",
92
+ ),
93
+ ] = None,
94
+ ) -> None:
95
+ """Scan directories for exact and potential duplicate files."""
96
+ from clutterxp.cli.app import get_globals
97
+
98
+ opts: GlobalOptions = get_globals(ctx)
99
+ cache_path = paths.cache_db_path()
100
+
101
+ with handle_errors(json_mode=opts.json_mode, command_name="duplicates_scan"):
102
+ # Validate folders exist
103
+ for p in paths_arg:
104
+ if not p.exists():
105
+ sys.stderr.write(f"error: Directory not found: {p}\n")
106
+ raise typer.Exit(code=2)
107
+
108
+ try:
109
+ min_size_bytes = parse_size(min_size)
110
+ except Exception:
111
+ sys.stderr.write(f"error: Invalid min-size '{min_size}'\n")
112
+ raise typer.Exit(code=2) from None
113
+
114
+ cache = HashCache(cache_path)
115
+ cancel_event = threading.Event()
116
+ detector = DuplicateDetectorImpl(cache=cache, cancel=cancel_event)
117
+
118
+ try:
119
+ groups = detector.scan(
120
+ paths_arg,
121
+ recursive=recursive,
122
+ min_size=min_size_bytes,
123
+ exclude=exclude if exclude else (),
124
+ jobs=jobs,
125
+ potential=not no_potential,
126
+ )
127
+ except (KeyboardInterrupt, InterruptedError):
128
+ if not opts.quiet and not opts.json_mode:
129
+ get_console(no_color=opts.no_color).print("\nScan interrupted. Cache preserved.")
130
+ raise typer.Exit(code=0) from None
131
+
132
+ is_json = opts.json_mode or output_format.lower() == "json"
133
+ is_csv = output_format.lower() == "csv"
134
+
135
+ if is_json:
136
+ result_json = groups_to_json(
137
+ groups,
138
+ paths_arg,
139
+ detector.last_scanned_files,
140
+ detector.last_scanned_bytes,
141
+ )
142
+ if output_file is not None:
143
+ import json
144
+
145
+ output_file.write_text(json.dumps(result_json, indent=2) + "\n", encoding="utf-8")
146
+ else:
147
+ print_json(result_json)
148
+ elif is_csv:
149
+ rows = groups_to_csv_rows(groups)
150
+ out_buf = io.StringIO()
151
+ csv_writer = csv.writer(out_buf)
152
+ csv_writer.writerows(rows)
153
+ csv_text = out_buf.getvalue()
154
+
155
+ if output_file is not None:
156
+ output_file.write_text(csv_text, encoding="utf-8")
157
+ else:
158
+ sys.stdout.write(csv_text)
159
+ sys.stdout.flush()
160
+ else:
161
+ # Table format
162
+ console = get_console(no_color=opts.no_color)
163
+ exact_groups = [g for g in groups if g.kind == "exact"]
164
+ hardlink_groups = [g for g in groups if g.kind == "hardlink"]
165
+ potential_groups = [g for g in groups if g.kind == "potential"]
166
+
167
+ total_wasted = sum(g.wasted_bytes for g in exact_groups)
168
+
169
+ console.print(
170
+ f"[bold]Duplicates Scan Summary[/bold]: {len(exact_groups)} exact groups "
171
+ f"({total_wasted} bytes wasted), {len(potential_groups)} potential groups, "
172
+ f"{len(hardlink_groups)} hardlinks\n"
173
+ )
174
+
175
+ if exact_groups:
176
+ table = Table(title="Exact Duplicate Groups", header_style="bold green")
177
+ table.add_column("SHA-256", style="dim")
178
+ table.add_column("Size", justify="right")
179
+ table.add_column("Wasted", justify="right")
180
+ table.add_column("Files (oldest first)")
181
+
182
+ for g in exact_groups:
183
+ sha = (g.sha256 or "")[:12] + "..."
184
+ size_str = str(g.files[0].size) if g.files else "0"
185
+ files_str = "\n".join(safe_str(f.path) for f in g.files)
186
+ table.add_row(sha, size_str, str(g.wasted_bytes), files_str)
187
+ console.print(table)
188
+ console.print()
189
+
190
+ if potential_groups:
191
+ table = Table(
192
+ title="Potential Duplicates (review manually)",
193
+ header_style="bold yellow",
194
+ )
195
+ table.add_column("Note")
196
+ table.add_column("Files")
197
+
198
+ for g in potential_groups:
199
+ files_str = "\n".join(safe_str(f.path) for f in g.files)
200
+ table.add_row(g.note, files_str)
201
+ console.print(table)
202
+ console.print()
203
+
204
+
205
+ @duplicates_app.command("cache")
206
+ def duplicates_cache_command(
207
+ ctx: typer.Context,
208
+ clear: Annotated[
209
+ bool,
210
+ typer.Option(
211
+ "--clear",
212
+ help="Clear all cached file hashes",
213
+ ),
214
+ ] = False,
215
+ ) -> None:
216
+ """Manage the duplicate detection hash cache."""
217
+ from clutterxp.cli.app import get_globals
218
+
219
+ opts: GlobalOptions = get_globals(ctx)
220
+ cache_path = paths.cache_db_path()
221
+
222
+ with handle_errors(json_mode=opts.json_mode, command_name="duplicates_cache"):
223
+ cache = HashCache(cache_path)
224
+ if clear:
225
+ cache.clear()
226
+ if opts.json_mode:
227
+ print_json(
228
+ {
229
+ "schema_version": 1,
230
+ "command": "duplicates_cache",
231
+ "status": "cleared",
232
+ "path": safe_str(cache_path),
233
+ }
234
+ )
235
+ else:
236
+ console = get_console(no_color=opts.no_color)
237
+ console.print(f"Duplicate hash cache cleared ({safe_str(cache_path)}).")
238
+ else:
239
+ if opts.json_mode:
240
+ print_json(
241
+ {
242
+ "schema_version": 1,
243
+ "command": "duplicates_cache",
244
+ "path": safe_str(cache_path),
245
+ "exists": cache_path.exists(),
246
+ }
247
+ )
248
+ else:
249
+ console = get_console(no_color=opts.no_color)
250
+ console.print(f"Hash cache path: {safe_str(cache_path)}")
251
+
252
+
253
+ __all__ = ["duplicates_app"]