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.
- clutterxp/__init__.py +4 -0
- clutterxp/cli/__init__.py +7 -0
- clutterxp/cli/app.py +251 -0
- clutterxp/cli/cmd_config.py +250 -0
- clutterxp/cli/cmd_doctor.py +449 -0
- clutterxp/cli/cmd_duplicates.py +253 -0
- clutterxp/cli/cmd_gui.py +22 -0
- clutterxp/cli/cmd_history.py +233 -0
- clutterxp/cli/cmd_init.py +76 -0
- clutterxp/cli/cmd_organize.py +226 -0
- clutterxp/cli/cmd_rules.py +540 -0
- clutterxp/cli/cmd_scan.py +109 -0
- clutterxp/cli/cmd_service.py +130 -0
- clutterxp/cli/cmd_undo.py +180 -0
- clutterxp/cli/cmd_watch.py +147 -0
- clutterxp/cli/errors.py +166 -0
- clutterxp/cli/render.py +390 -0
- clutterxp/config/__init__.py +40 -0
- clutterxp/config/jsonschema.py +12 -0
- clutterxp/config/loader.py +212 -0
- clutterxp/config/placeholders.py +79 -0
- clutterxp/config/runtime.py +30 -0
- clutterxp/config/schema.py +166 -0
- clutterxp/config/validate.py +226 -0
- clutterxp/config/writer.py +248 -0
- clutterxp/config.example.yaml +76 -0
- clutterxp/duplicates/__init__.py +22 -0
- clutterxp/duplicates/detector.py +438 -0
- clutterxp/duplicates/hashcache.py +149 -0
- clutterxp/duplicates/namenorm.py +45 -0
- clutterxp/duplicates/quarantine.py +196 -0
- clutterxp/duplicates/report.py +110 -0
- clutterxp/gui/__init__.py +1 -0
- clutterxp/gui/api.py +494 -0
- clutterxp/gui/app.py +205 -0
- clutterxp/gui/models_qt.py +544 -0
- clutterxp/gui/views/__init__.py +21 -0
- clutterxp/gui/views/dashboard.py +253 -0
- clutterxp/gui/views/duplicates.py +404 -0
- clutterxp/gui/views/folders.py +354 -0
- clutterxp/gui/views/history.py +281 -0
- clutterxp/gui/views/preview.py +277 -0
- clutterxp/gui/views/rules.py +527 -0
- clutterxp/gui/views/settings.py +172 -0
- clutterxp/gui/workers.py +147 -0
- clutterxp/history/__init__.py +23 -0
- clutterxp/history/db.py +174 -0
- clutterxp/history/pathcodec.py +42 -0
- clutterxp/history/recovery.py +318 -0
- clutterxp/history/schema.sql +43 -0
- clutterxp/history/service.py +493 -0
- clutterxp/history/undo.py +373 -0
- clutterxp/interfaces.py +164 -0
- clutterxp/locking.py +113 -0
- clutterxp/logging_setup.py +73 -0
- clutterxp/models.py +266 -0
- clutterxp/mover/__init__.py +26 -0
- clutterxp/mover/collision.py +46 -0
- clutterxp/mover/fsops.py +191 -0
- clutterxp/mover/safe_move.py +741 -0
- clutterxp/organizer.py +388 -0
- clutterxp/packaging/clutterxp.service +15 -0
- clutterxp/paths.py +112 -0
- clutterxp/planner.py +339 -0
- clutterxp/py.typed +0 -0
- clutterxp/rules/__init__.py +16 -0
- clutterxp/rules/engine.py +374 -0
- clutterxp/rules/matcher.py +222 -0
- clutterxp/rules/template.py +171 -0
- clutterxp/rules/tokenizer.py +118 -0
- clutterxp/safety.py +111 -0
- clutterxp/scanner.py +196 -0
- clutterxp/service_install.py +208 -0
- clutterxp/units.py +59 -0
- clutterxp/watch/__init__.py +37 -0
- clutterxp/watch/debounce.py +84 -0
- clutterxp/watch/events.py +259 -0
- clutterxp/watch/ignore.py +77 -0
- clutterxp/watch/service.py +662 -0
- clutterxp/watch/settle.py +170 -0
- clutterxp-1.1.0.dist-info/METADATA +149 -0
- clutterxp-1.1.0.dist-info/RECORD +84 -0
- clutterxp-1.1.0.dist-info/WHEEL +4 -0
- 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"]
|