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,540 @@
1
+ """Command handlers for `clutterxp rules` subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated, Any
8
+
9
+ import typer
10
+
11
+ from clutterxp import paths
12
+ from clutterxp.cli.errors import handle_errors
13
+ from clutterxp.cli.render import (
14
+ get_console,
15
+ print_json,
16
+ render_rule_trace,
17
+ render_rules_table,
18
+ safe_str,
19
+ )
20
+ from clutterxp.config import loader, writer
21
+ from clutterxp.models import CollisionPolicy, FileInfo, MatchMode
22
+ from clutterxp.rules import build_engine
23
+ from clutterxp.rules.tokenizer import camel_tokens, raw_tokens
24
+
25
+ if TYPE_CHECKING:
26
+ from clutterxp.cli.app import GlobalOptions
27
+
28
+ rules_app = typer.Typer(
29
+ help="Manage and test file organization rules",
30
+ no_args_is_help=True,
31
+ )
32
+
33
+
34
+ @rules_app.command("list")
35
+ def rules_list_command(
36
+ ctx: typer.Context,
37
+ all_rules: Annotated[
38
+ bool,
39
+ typer.Option(
40
+ "--all",
41
+ "-a",
42
+ help="Include disabled rules in the output",
43
+ ),
44
+ ] = False,
45
+ ) -> None:
46
+ """List configured rules in priority order."""
47
+ from clutterxp.cli.app import get_globals
48
+
49
+ opts: GlobalOptions = get_globals(ctx)
50
+ target = opts.config if opts.config is not None else paths.config_path()
51
+
52
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_list"):
53
+ cfg = loader.load(target)
54
+ rules_to_show = cfg.all_rules if all_rules else cfg.rules
55
+
56
+ if opts.json_mode:
57
+ rules_data = []
58
+ for r in rules_to_show:
59
+ data = {
60
+ "name": r.name,
61
+ "priority": r.priority,
62
+ "enabled": r.enabled,
63
+ "keywords": list(r.keywords),
64
+ "all_of": list(r.all_of),
65
+ "exclude": list(r.exclude),
66
+ "extensions": list(r.extensions),
67
+ "destination": safe_str(r.destination),
68
+ "collision": r.collision.value
69
+ if r.collision is not None
70
+ else CollisionPolicy.SKIP.value,
71
+ "match": r.match.value if r.match is not None else MatchMode.TOKEN.value,
72
+ }
73
+ rules_data.append(data)
74
+ print_json({"schema_version": 1, "rules": rules_data})
75
+ else:
76
+ render_rules_table(rules_to_show, no_color=opts.no_color)
77
+
78
+
79
+ @rules_app.command("show")
80
+ def rules_show_command(
81
+ ctx: typer.Context,
82
+ name: Annotated[str, typer.Argument(help="Name of the rule to inspect")],
83
+ ) -> None:
84
+ """Show details of a specific rule."""
85
+ from clutterxp.cli.app import get_globals
86
+
87
+ opts: GlobalOptions = get_globals(ctx)
88
+ target = opts.config if opts.config is not None else paths.config_path()
89
+
90
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_show"):
91
+ cfg = loader.load(target)
92
+ found = None
93
+ for r in cfg.all_rules:
94
+ if r.name.casefold() == name.casefold():
95
+ found = r
96
+ break
97
+
98
+ if found is None:
99
+ sys.stderr.write(f"error: Rule '{name}' not found\n")
100
+ raise typer.Exit(code=2)
101
+
102
+ data = {
103
+ "name": found.name,
104
+ "priority": found.priority,
105
+ "enabled": found.enabled,
106
+ "keywords": list(found.keywords),
107
+ "all_of": list(found.all_of),
108
+ "exclude": list(found.exclude),
109
+ "extensions": list(found.extensions),
110
+ "destination": safe_str(found.destination),
111
+ "collision": found.collision.value
112
+ if found.collision is not None
113
+ else CollisionPolicy.SKIP.value,
114
+ "match": found.match.value if found.match is not None else MatchMode.TOKEN.value,
115
+ }
116
+ if found.conditions:
117
+ data["conditions"] = found.conditions.model_dump()
118
+
119
+ if opts.json_mode:
120
+ print_json({"schema_version": 1, "rule": data})
121
+ else:
122
+ console = get_console(no_color=opts.no_color)
123
+ console.print(f"[bold]Rule:[/bold] {found.name}")
124
+ console.print(f" Priority: {found.priority}")
125
+ console.print(f" Enabled: {found.enabled}")
126
+ console.print(f" Keywords: {', '.join(found.keywords) or '(none)'}")
127
+ console.print(f" All-of: {', '.join(found.all_of) or '(none)'}")
128
+ console.print(f" Exclude: {', '.join(found.exclude) or '(none)'}")
129
+ console.print(f" Extensions: {', '.join(found.extensions) or '(none)'}")
130
+ console.print(f" Destination: {safe_str(found.destination)}")
131
+ coll_val = (
132
+ found.collision.value if found.collision is not None else CollisionPolicy.SKIP.value
133
+ )
134
+ match_val = found.match.value if found.match is not None else MatchMode.TOKEN.value
135
+ console.print(f" Collision: {coll_val}")
136
+ console.print(f" Match mode: {match_val}")
137
+
138
+
139
+ @rules_app.command("add")
140
+ def rules_add_command(
141
+ ctx: typer.Context,
142
+ name: Annotated[
143
+ str | None,
144
+ typer.Option("--name", "-n", help="Rule name"),
145
+ ] = None,
146
+ keyword: Annotated[
147
+ list[str] | None,
148
+ typer.Option("--keyword", "-k", help="Keyword (repeatable)"),
149
+ ] = None,
150
+ all_of: Annotated[
151
+ list[str] | None,
152
+ typer.Option("--all-of", help="Required keyword (repeatable)"),
153
+ ] = None,
154
+ exclude: Annotated[
155
+ list[str] | None,
156
+ typer.Option("--exclude", help="Excluded keyword (repeatable)"),
157
+ ] = None,
158
+ ext: Annotated[
159
+ list[str] | None,
160
+ typer.Option("--ext", "-e", help="Extension filter (repeatable)"),
161
+ ] = None,
162
+ dest: Annotated[
163
+ str | None,
164
+ typer.Option("--dest", "-d", help="Destination path template"),
165
+ ] = None,
166
+ priority: Annotated[
167
+ int,
168
+ typer.Option("--priority", "-p", help="Priority (lower runs first)"),
169
+ ] = 20,
170
+ collision: Annotated[
171
+ CollisionPolicy,
172
+ typer.Option("--collision", help="Collision resolution policy"),
173
+ ] = CollisionPolicy.SKIP,
174
+ match: Annotated[
175
+ MatchMode,
176
+ typer.Option("--match", "-m", help="Keyword matching mode"),
177
+ ] = MatchMode.TOKEN,
178
+ ) -> None:
179
+ """Add a new rule to config.yaml (prompts interactively for missing values)."""
180
+ from clutterxp.cli.app import get_globals
181
+
182
+ opts: GlobalOptions = get_globals(ctx)
183
+ target = opts.config if opts.config is not None else paths.config_path()
184
+
185
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_add"):
186
+ # Interactive prompts if required values are missing
187
+ rule_name = name
188
+ if not rule_name:
189
+ if opts.json_mode:
190
+ sys.stderr.write("error: Missing required --name in --json mode\n")
191
+ raise typer.Exit(code=2)
192
+ rule_name = typer.prompt("Rule name")
193
+
194
+ rule_dest = dest
195
+ if not rule_dest:
196
+ if opts.json_mode:
197
+ sys.stderr.write("error: Missing required --dest in --json mode\n")
198
+ raise typer.Exit(code=2)
199
+ rule_dest = typer.prompt("Destination directory template")
200
+
201
+ keywords = list(keyword) if keyword else []
202
+ extensions = list(ext) if ext else []
203
+
204
+ if not keywords and not extensions:
205
+ if opts.json_mode:
206
+ sys.stderr.write(
207
+ "error: Rule must have at least one keyword or extension in --json mode\n"
208
+ )
209
+ raise typer.Exit(code=2)
210
+ kw_input = typer.prompt("Keywords (space or comma-separated)", default="")
211
+ if kw_input:
212
+ keywords = [k.strip() for k in kw_input.replace(",", " ").split() if k.strip()]
213
+
214
+ rule_dict: dict[str, Any] = {
215
+ "name": rule_name,
216
+ "destination": rule_dest,
217
+ "priority": priority,
218
+ "collision": collision.value,
219
+ "match": match.value,
220
+ "keywords": keywords,
221
+ "all_of": list(all_of) if all_of else [],
222
+ "exclude": list(exclude) if exclude else [],
223
+ "extensions": extensions,
224
+ }
225
+
226
+ writer.add_rule(target, rule_dict)
227
+
228
+ if opts.json_mode:
229
+ print_json(
230
+ {
231
+ "schema_version": 1,
232
+ "command": "rules_add",
233
+ "status": "added",
234
+ "rule": rule_dict,
235
+ }
236
+ )
237
+ else:
238
+ console = get_console(no_color=opts.no_color)
239
+ console.print(f"Rule '{rule_name}' added successfully to {safe_str(target)}")
240
+
241
+
242
+ @rules_app.command("edit")
243
+ def rules_edit_command(
244
+ ctx: typer.Context,
245
+ name: Annotated[str, typer.Argument(help="Rule name to edit")],
246
+ dest: Annotated[
247
+ str | None,
248
+ typer.Option("--dest", "-d", help="New destination"),
249
+ ] = None,
250
+ priority: Annotated[
251
+ int | None,
252
+ typer.Option("--priority", "-p", help="New priority"),
253
+ ] = None,
254
+ collision: Annotated[
255
+ CollisionPolicy | None,
256
+ typer.Option("--collision", help="New collision policy"),
257
+ ] = None,
258
+ match: Annotated[
259
+ MatchMode | None,
260
+ typer.Option("--match", "-m", help="New match mode"),
261
+ ] = None,
262
+ ) -> None:
263
+ """Edit fields of an existing rule."""
264
+ from clutterxp.cli.app import get_globals
265
+
266
+ opts: GlobalOptions = get_globals(ctx)
267
+ target = opts.config if opts.config is not None else paths.config_path()
268
+
269
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_edit"):
270
+ updates: dict[str, Any] = {}
271
+ if dest is not None:
272
+ updates["destination"] = dest
273
+ if priority is not None:
274
+ updates["priority"] = priority
275
+ if collision is not None:
276
+ updates["collision"] = collision.value
277
+ if match is not None:
278
+ updates["match"] = match.value
279
+
280
+ if not updates:
281
+ sys.stderr.write("error: No fields provided to edit\n")
282
+ raise typer.Exit(code=2)
283
+
284
+ writer.edit_rule(target, name, updates)
285
+
286
+ if opts.json_mode:
287
+ print_json(
288
+ {
289
+ "schema_version": 1,
290
+ "command": "rules_edit",
291
+ "name": name,
292
+ "status": "updated",
293
+ "updates": updates,
294
+ }
295
+ )
296
+ else:
297
+ console = get_console(no_color=opts.no_color)
298
+ console.print(f"Rule '{name}' updated successfully in {safe_str(target)}")
299
+
300
+
301
+ @rules_app.command("remove")
302
+ def rules_remove_command(
303
+ ctx: typer.Context,
304
+ name: Annotated[str, typer.Argument(help="Rule name to remove")],
305
+ yes: Annotated[
306
+ bool,
307
+ typer.Option("--yes", "-y", help="Skip confirmation prompt"),
308
+ ] = False,
309
+ ) -> None:
310
+ """Remove a rule from config.yaml."""
311
+ from clutterxp.cli.app import get_globals
312
+
313
+ opts: GlobalOptions = get_globals(ctx)
314
+ target = opts.config if opts.config is not None else paths.config_path()
315
+
316
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_remove"):
317
+ if (
318
+ not yes
319
+ and not opts.json_mode
320
+ and not typer.confirm(f"Remove rule '{name}'?", default=False)
321
+ ):
322
+ get_console(no_color=opts.no_color).print("Aborted.")
323
+ raise typer.Exit(code=0)
324
+
325
+ writer.remove_rule(target, name)
326
+
327
+ if opts.json_mode:
328
+ print_json(
329
+ {
330
+ "schema_version": 1,
331
+ "command": "rules_remove",
332
+ "name": name,
333
+ "status": "removed",
334
+ }
335
+ )
336
+ else:
337
+ console = get_console(no_color=opts.no_color)
338
+ console.print(f"Rule '{name}' removed successfully from {safe_str(target)}")
339
+
340
+
341
+ @rules_app.command("enable")
342
+ def rules_enable_command(
343
+ ctx: typer.Context,
344
+ name: Annotated[str, typer.Argument(help="Rule name to enable")],
345
+ ) -> None:
346
+ """Enable a rule in config.yaml."""
347
+ from clutterxp.cli.app import get_globals
348
+
349
+ opts: GlobalOptions = get_globals(ctx)
350
+ target = opts.config if opts.config is not None else paths.config_path()
351
+
352
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_enable"):
353
+ writer.enable_rule(target, name)
354
+
355
+ if opts.json_mode:
356
+ print_json(
357
+ {
358
+ "schema_version": 1,
359
+ "command": "rules_enable",
360
+ "name": name,
361
+ "status": "enabled",
362
+ }
363
+ )
364
+ else:
365
+ console = get_console(no_color=opts.no_color)
366
+ console.print(f"Rule '{name}' enabled.")
367
+
368
+
369
+ @rules_app.command("disable")
370
+ def rules_disable_command(
371
+ ctx: typer.Context,
372
+ name: Annotated[str, typer.Argument(help="Rule name to disable")],
373
+ ) -> None:
374
+ """Disable a rule in config.yaml."""
375
+ from clutterxp.cli.app import get_globals
376
+
377
+ opts: GlobalOptions = get_globals(ctx)
378
+ target = opts.config if opts.config is not None else paths.config_path()
379
+
380
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_disable"):
381
+ writer.disable_rule(target, name)
382
+
383
+ if opts.json_mode:
384
+ print_json(
385
+ {
386
+ "schema_version": 1,
387
+ "command": "rules_disable",
388
+ "name": name,
389
+ "status": "disabled",
390
+ }
391
+ )
392
+ else:
393
+ console = get_console(no_color=opts.no_color)
394
+ console.print(f"Rule '{name}' disabled.")
395
+
396
+
397
+ @rules_app.command("test")
398
+ def rules_test_command(
399
+ ctx: typer.Context,
400
+ filename: Annotated[str, typer.Argument(help="Filename to evaluate against rules")],
401
+ file: Annotated[
402
+ Path | None,
403
+ typer.Option(
404
+ "--file",
405
+ help="Path to real file on disk to stat for size and mtime",
406
+ ),
407
+ ] = None,
408
+ folder: Annotated[
409
+ Path | None,
410
+ typer.Option(
411
+ "--folder",
412
+ help="Watched root folder context for per-folder rule filtering",
413
+ ),
414
+ ] = None,
415
+ ) -> None:
416
+ """Explain every rule's match/non-match for a filename (docs/02 section 8)."""
417
+ from clutterxp.cli.app import get_globals
418
+
419
+ opts: GlobalOptions = get_globals(ctx)
420
+ target = opts.config if opts.config is not None else paths.config_path()
421
+
422
+ with handle_errors(json_mode=opts.json_mode, command_name="rules_test"):
423
+ cfg = loader.load(target)
424
+ engine = build_engine(
425
+ list(cfg.rules),
426
+ cfg.settings,
427
+ watch=list(cfg.watch_entries),
428
+ )
429
+
430
+ if file is not None:
431
+ if not file.exists():
432
+ sys.stderr.write(f"error: File not found: {file}\n")
433
+ raise typer.Exit(code=2)
434
+ info = FileInfo.from_path(file)
435
+ else:
436
+ p = Path(filename)
437
+ info = FileInfo(
438
+ path=p,
439
+ name=p.name,
440
+ stem=p.stem,
441
+ ext=p.suffix[1:].lower() if p.suffix else "",
442
+ size=0,
443
+ mtime_ns=0,
444
+ inode=0,
445
+ device=0,
446
+ is_regular=True,
447
+ is_symlink=False,
448
+ )
449
+
450
+ raw_toks = raw_tokens(info.stem)
451
+ camel_toks = camel_tokens(info.stem)
452
+ traces = engine.trace(info)
453
+ decision = engine.decide(info, folder=folder)
454
+
455
+ # Map rules to destinations for trace output
456
+ rule_map = {r.name.casefold(): r for r in cfg.rules}
457
+
458
+ if opts.json_mode:
459
+ traces_json = []
460
+ for t in traces:
461
+ r_spec = rule_map.get(t.rule_name.casefold())
462
+ dest_str = safe_str(r_spec.destination) if r_spec else ""
463
+ traces_json.append(
464
+ {
465
+ "rule": t.rule_name,
466
+ "priority": t.priority,
467
+ "matched": t.matched,
468
+ "reasons": list(t.reasons),
469
+ "destination": dest_str,
470
+ }
471
+ )
472
+
473
+ hit_rule = decision.hit.rule_name if decision.hit else None
474
+ hit_dest = safe_str(decision.hit.dest_dir) if decision.hit else None
475
+ hit_reason = (
476
+ decision.hit.matched_by if decision.hit else (decision.reason or "no rule matched")
477
+ )
478
+ candidates = [c.rule_name for c in decision.candidates]
479
+
480
+ print_json(
481
+ {
482
+ "schema_version": 1,
483
+ "file": {
484
+ "name": info.name,
485
+ "path": safe_str(info.path),
486
+ "size": info.size,
487
+ "ext": info.ext,
488
+ "mtime_ns": info.mtime_ns,
489
+ },
490
+ "tokens": {
491
+ "raw": list(raw_toks),
492
+ "camel": list(camel_toks),
493
+ },
494
+ "traces": traces_json,
495
+ "decision": {
496
+ "kind": decision.kind.value,
497
+ "rule": hit_rule,
498
+ "destination": hit_dest,
499
+ "reason": hit_reason,
500
+ "candidates": candidates,
501
+ },
502
+ }
503
+ )
504
+ else:
505
+ hit_rule = decision.hit.rule_name if decision.hit else None
506
+ hit_dest = safe_str(decision.hit.dest_dir) if decision.hit else None
507
+ hit_reason = decision.hit.matched_by if decision.hit else decision.reason
508
+ render_rule_trace(
509
+ info.name,
510
+ info.ext,
511
+ info.size,
512
+ raw_toks,
513
+ camel_toks,
514
+ traces,
515
+ decision.kind,
516
+ hit_rule,
517
+ hit_dest,
518
+ hit_reason,
519
+ no_color=opts.no_color,
520
+ )
521
+
522
+
523
+ @rules_app.command("check")
524
+ def rules_check_command(
525
+ ctx: typer.Context,
526
+ strict: Annotated[
527
+ bool,
528
+ typer.Option(
529
+ "--strict",
530
+ help="Treat warnings as errors (exit code 3)",
531
+ ),
532
+ ] = False,
533
+ ) -> None:
534
+ """Check rules for overlaps, equal-priority shared keywords, and ambiguities."""
535
+ from clutterxp.cli.cmd_config import config_validate_command
536
+
537
+ config_validate_command(ctx, strict=strict)
538
+
539
+
540
+ __all__ = ["rules_app"]
@@ -0,0 +1,109 @@
1
+ """Command handler for `clutterxp scan`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Annotated, Any
7
+
8
+ import typer
9
+
10
+ from clutterxp.cli.errors import handle_errors
11
+ from clutterxp.cli.render import print_json, render_plan, safe_str
12
+ from clutterxp.models import PlanStatus
13
+
14
+ if TYPE_CHECKING:
15
+ from clutterxp.cli.app import GlobalOptions
16
+
17
+
18
+ def scan_command(
19
+ ctx: typer.Context,
20
+ paths: Annotated[
21
+ list[Path] | None,
22
+ typer.Argument(
23
+ help="Folders or files to scan (defaults to configured watch folders)",
24
+ ),
25
+ ] = None,
26
+ recursive: Annotated[
27
+ bool,
28
+ typer.Option(
29
+ "--recursive",
30
+ "-r",
31
+ help="Scan directories recursively",
32
+ ),
33
+ ] = False,
34
+ show: Annotated[
35
+ str,
36
+ typer.Option(
37
+ "--show",
38
+ help="Filter plan output: all | matched | unmatched | ambiguous | blocked",
39
+ ),
40
+ ] = "all",
41
+ strict: Annotated[
42
+ bool,
43
+ typer.Option(
44
+ "--strict",
45
+ help="Exit with code 3 if any file is ambiguous",
46
+ ),
47
+ ] = False,
48
+ ) -> None:
49
+ """Scan folders, classify files, and preview sorting decisions without modifying anything."""
50
+ from clutterxp.cli.app import build_organizer, get_globals
51
+
52
+ opts: GlobalOptions = get_globals(ctx)
53
+
54
+ with handle_errors(json_mode=opts.json_mode, command_name="scan"):
55
+ organizer = build_organizer(opts.config)
56
+ plan = organizer.plan(paths=paths if paths else None, recursive=recursive)
57
+
58
+ matched_count = plan.count(PlanStatus.WILL_MOVE) + plan.count(PlanStatus.WILL_MOVE_RENAMED)
59
+ unmatched_count = plan.count(PlanStatus.NO_MATCH) + plan.count(PlanStatus.IGNORED)
60
+ ambig_count = plan.count(PlanStatus.AMBIGUOUS)
61
+ blocked_count = plan.count(PlanStatus.BLOCKED)
62
+
63
+ if opts.json_mode:
64
+ actions_json = []
65
+ for a in plan.actions:
66
+ status_str = a.status.value
67
+ act_data: dict[str, Any] = {
68
+ "status": status_str,
69
+ "src": safe_str(a.file.path),
70
+ }
71
+ if a.dest_path is not None:
72
+ act_data["dst"] = safe_str(a.dest_path)
73
+ if a.decision.hit is not None:
74
+ act_data["rule"] = a.decision.hit.rule_name
75
+ act_data["matched_by"] = a.decision.hit.matched_by
76
+ if a.decision.candidates:
77
+ act_data["candidates"] = [c.rule_name for c in a.decision.candidates]
78
+ if a.status == PlanStatus.BLOCKED:
79
+ act_data["code"] = a.code.value
80
+ act_data["message"] = a.message or a.code.value
81
+ actions_json.append(act_data)
82
+
83
+ print_json(
84
+ {
85
+ "schema_version": 1,
86
+ "command": "scan",
87
+ "dry_run": True,
88
+ "summary": {
89
+ "matched": matched_count,
90
+ "unmatched": unmatched_count,
91
+ "ambiguous": ambig_count,
92
+ "blocked": blocked_count,
93
+ },
94
+ "actions": actions_json,
95
+ }
96
+ )
97
+ else:
98
+ render_plan(
99
+ plan,
100
+ dry_run=True,
101
+ show=show.lower(),
102
+ no_color=opts.no_color,
103
+ )
104
+
105
+ if strict and ambig_count > 0:
106
+ raise typer.Exit(code=3)
107
+
108
+
109
+ __all__ = ["scan_command"]