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
clutterxp/cli/render.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Console rendering with Rich, safe string escaping, and JSON output formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Generator, Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeRemainingColumn
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from clutterxp.models import (
|
|
18
|
+
BatchResult,
|
|
19
|
+
DecisionKind,
|
|
20
|
+
OperationRecord,
|
|
21
|
+
Plan,
|
|
22
|
+
PlanStatus,
|
|
23
|
+
RuleTrace,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def safe_str(val: Any) -> str:
|
|
28
|
+
"""Format any object to string, safely escaping non-UTF-8 surrogate characters.
|
|
29
|
+
|
|
30
|
+
Protects against UnicodeEncodeError when paths contain non-UTF-8 sequences.
|
|
31
|
+
"""
|
|
32
|
+
if val is None:
|
|
33
|
+
return ""
|
|
34
|
+
if isinstance(val, bytes):
|
|
35
|
+
return val.decode("utf-8", errors="backslashreplace")
|
|
36
|
+
s = str(val)
|
|
37
|
+
try:
|
|
38
|
+
s.encode("utf-8")
|
|
39
|
+
return s
|
|
40
|
+
except UnicodeEncodeError:
|
|
41
|
+
return s.encode("utf-8", errors="surrogateescape").decode(
|
|
42
|
+
"utf-8", errors="backslashreplace"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sanitize_for_json(data: Any) -> Any:
|
|
47
|
+
"""Recursively convert Paths and strings with surrogates into clean JSON types."""
|
|
48
|
+
if isinstance(data, Path):
|
|
49
|
+
return safe_str(data)
|
|
50
|
+
if isinstance(data, str):
|
|
51
|
+
return safe_str(data)
|
|
52
|
+
if isinstance(data, (int, float, bool)) or data is None:
|
|
53
|
+
return data
|
|
54
|
+
if isinstance(data, dict):
|
|
55
|
+
return {_sanitize_for_json(k): _sanitize_for_json(v) for k, v in data.items()}
|
|
56
|
+
if isinstance(data, (list, tuple, set)):
|
|
57
|
+
return [_sanitize_for_json(item) for item in data]
|
|
58
|
+
return safe_str(data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_json(data: dict[str, Any]) -> None:
|
|
62
|
+
"""Emit ONLY pure valid JSON to stdout with no Rich formatting."""
|
|
63
|
+
cleaned = _sanitize_for_json(data)
|
|
64
|
+
text = json.dumps(cleaned, indent=2)
|
|
65
|
+
sys.stdout.write(text + "\n")
|
|
66
|
+
sys.stdout.flush()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_console(
|
|
70
|
+
*,
|
|
71
|
+
no_color: bool = False,
|
|
72
|
+
stderr: bool = False,
|
|
73
|
+
) -> Console:
|
|
74
|
+
"""Get a Rich Console instance configured for color settings."""
|
|
75
|
+
env_no_color = bool(os.environ.get("NO_COLOR", "").strip())
|
|
76
|
+
force_no_color = no_color or env_no_color
|
|
77
|
+
return Console(
|
|
78
|
+
file=sys.stderr if stderr else sys.stdout,
|
|
79
|
+
no_color=force_no_color,
|
|
80
|
+
highlight=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def progress_bar(
|
|
86
|
+
description: str,
|
|
87
|
+
total: int | None = None,
|
|
88
|
+
*,
|
|
89
|
+
quiet: bool = False,
|
|
90
|
+
json_mode: bool = False,
|
|
91
|
+
no_color: bool = False,
|
|
92
|
+
) -> Generator[Progress | None, None, None]:
|
|
93
|
+
"""Create a Rich progress bar rendered to stderr, active only when appropriate."""
|
|
94
|
+
if quiet or json_mode or not sys.stderr.isatty():
|
|
95
|
+
yield None
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
stderr_console = get_console(no_color=no_color, stderr=True)
|
|
99
|
+
with Progress(
|
|
100
|
+
SpinnerColumn(),
|
|
101
|
+
TextColumn("[bold blue]{task.description}"),
|
|
102
|
+
BarColumn(),
|
|
103
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
104
|
+
TimeRemainingColumn(),
|
|
105
|
+
console=stderr_console,
|
|
106
|
+
transient=True,
|
|
107
|
+
) as progress:
|
|
108
|
+
progress.add_task(description, total=total)
|
|
109
|
+
yield progress
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render_plan(
|
|
113
|
+
plan: Plan,
|
|
114
|
+
*,
|
|
115
|
+
dry_run: bool = False,
|
|
116
|
+
show: str = "all",
|
|
117
|
+
collapse_keep_limit: int = 10,
|
|
118
|
+
no_color: bool = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Render human-readable execution plan with status words MOVE, KEEP, AMBIG, BLOCK."""
|
|
121
|
+
console = get_console(no_color=no_color)
|
|
122
|
+
|
|
123
|
+
# Header
|
|
124
|
+
folder_str = ", ".join(safe_str(f) for f in plan.folders) if plan.folders else "targets"
|
|
125
|
+
total_files = len(plan.actions)
|
|
126
|
+
console.print(f"Plan for {folder_str} ({total_files} files)\n")
|
|
127
|
+
|
|
128
|
+
# Filter actions according to show
|
|
129
|
+
will_move_count = 0
|
|
130
|
+
unmatched_count = 0
|
|
131
|
+
ambig_count = 0
|
|
132
|
+
blocked_count = 0
|
|
133
|
+
|
|
134
|
+
actions_to_show: list[tuple[str, str]] = []
|
|
135
|
+
collapsed_keep = 0
|
|
136
|
+
|
|
137
|
+
for action in plan.actions:
|
|
138
|
+
src_name = safe_str(action.file.name)
|
|
139
|
+
if action.status in (PlanStatus.WILL_MOVE, PlanStatus.WILL_MOVE_RENAMED):
|
|
140
|
+
will_move_count += 1
|
|
141
|
+
if show in ("all", "matched"):
|
|
142
|
+
dst_dir = safe_str(action.dest_path.parent) if action.dest_path else "unknown_dest"
|
|
143
|
+
rule_name = action.decision.hit.rule_name if action.decision.hit else ""
|
|
144
|
+
matched_by = action.decision.hit.matched_by if action.decision.hit else ""
|
|
145
|
+
info = f"[{rule_name} {matched_by}]" if rule_name else ""
|
|
146
|
+
actions_to_show.append(
|
|
147
|
+
("MOVE", f" {src_name:<30} -> {dst_dir:<32} {info}".strip())
|
|
148
|
+
)
|
|
149
|
+
elif action.status == PlanStatus.AMBIGUOUS:
|
|
150
|
+
ambig_count += 1
|
|
151
|
+
if show in ("all", "ambiguous"):
|
|
152
|
+
candidates = (
|
|
153
|
+
", ".join(c.rule_name for c in action.decision.candidates)
|
|
154
|
+
if action.decision.candidates
|
|
155
|
+
else "multiple rules"
|
|
156
|
+
)
|
|
157
|
+
actions_to_show.append(
|
|
158
|
+
("AMBIG", f" {src_name:<30} candidates: {candidates}".strip())
|
|
159
|
+
)
|
|
160
|
+
elif action.status == PlanStatus.BLOCKED:
|
|
161
|
+
blocked_count += 1
|
|
162
|
+
if show in ("all", "blocked"):
|
|
163
|
+
msg = action.message or action.code.value
|
|
164
|
+
actions_to_show.append(("BLOCK", f" {src_name:<30} {msg}".strip()))
|
|
165
|
+
else: # NO_MATCH or IGNORED
|
|
166
|
+
unmatched_count += 1
|
|
167
|
+
if show in ("all", "unmatched"):
|
|
168
|
+
reason = "left untouched"
|
|
169
|
+
if action.status == PlanStatus.IGNORED:
|
|
170
|
+
reason = "ignored"
|
|
171
|
+
elif action.decision.reason:
|
|
172
|
+
reason = f"{action.decision.reason}; left untouched"
|
|
173
|
+
else:
|
|
174
|
+
reason = "no rule matched; left untouched"
|
|
175
|
+
actions_to_show.append(("KEEP", f" {src_name:<30} {reason}".strip()))
|
|
176
|
+
|
|
177
|
+
# Check if we should collapse KEEP lines in large plans when show == "all"
|
|
178
|
+
for status_word, line_text in actions_to_show:
|
|
179
|
+
if status_word == "KEEP" and show == "all" and unmatched_count > collapse_keep_limit:
|
|
180
|
+
collapsed_keep += 1
|
|
181
|
+
if collapsed_keep <= 3:
|
|
182
|
+
_print_action_line(console, status_word, line_text, no_color=no_color)
|
|
183
|
+
elif collapsed_keep == 4:
|
|
184
|
+
rem = unmatched_count - 3
|
|
185
|
+
console.print(f" ... ({rem} files left untouched, use --show unmatched to view)")
|
|
186
|
+
# else skip printing remainder
|
|
187
|
+
else:
|
|
188
|
+
_print_action_line(console, status_word, line_text, no_color=no_color)
|
|
189
|
+
|
|
190
|
+
# Summary
|
|
191
|
+
console.print(
|
|
192
|
+
f"\nSummary: {will_move_count} will move, {unmatched_count} unmatched (untouched), "
|
|
193
|
+
f"{ambig_count} ambiguous, {blocked_count} blocked"
|
|
194
|
+
)
|
|
195
|
+
if dry_run:
|
|
196
|
+
console.print("Nothing was changed (dry run).")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _print_action_line(console: Console, status_word: str, text: str, no_color: bool) -> None:
|
|
200
|
+
"""Print an action line with color styling based on status word."""
|
|
201
|
+
if no_color:
|
|
202
|
+
console.print(f" {status_word:<6} {text}")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
if status_word == "MOVE":
|
|
206
|
+
console.print(f" [green bold]{status_word:<6}[/] {text}")
|
|
207
|
+
elif status_word == "AMBIG":
|
|
208
|
+
console.print(f" [yellow bold]{status_word:<6}[/] {text}")
|
|
209
|
+
elif status_word == "BLOCK":
|
|
210
|
+
console.print(f" [red bold]{status_word:<6}[/] {text}")
|
|
211
|
+
else: # KEEP
|
|
212
|
+
console.print(f" [dim]{status_word:<6}[/] [dim]{text}[/]")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_organize_summary(
|
|
216
|
+
plan: Plan,
|
|
217
|
+
batch_result: BatchResult,
|
|
218
|
+
*,
|
|
219
|
+
no_color: bool = False,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Print summary after applying an organize plan."""
|
|
222
|
+
console = get_console(no_color=no_color)
|
|
223
|
+
unmatched = plan.count(PlanStatus.NO_MATCH) + plan.count(PlanStatus.IGNORED)
|
|
224
|
+
ambiguous = plan.count(PlanStatus.AMBIGUOUS)
|
|
225
|
+
|
|
226
|
+
console.print(
|
|
227
|
+
f"\nSummary: {batch_result.moved} moved, {unmatched} unmatched (untouched), "
|
|
228
|
+
f"{ambiguous} ambiguous, {batch_result.blocked} blocked, {batch_result.failed} failed"
|
|
229
|
+
)
|
|
230
|
+
if batch_result.batch_id is not None:
|
|
231
|
+
console.print(f"Undo with: clutterxp undo --batch {batch_result.batch_id}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def render_rules_table(
|
|
235
|
+
rules: Sequence[Any],
|
|
236
|
+
*,
|
|
237
|
+
no_color: bool = False,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Render a table of rules (list command)."""
|
|
240
|
+
console = get_console(no_color=no_color)
|
|
241
|
+
table = Table(title="Configured Rules", header_style="bold")
|
|
242
|
+
table.add_column("Name", style="bold")
|
|
243
|
+
table.add_column("Priority", justify="right")
|
|
244
|
+
table.add_column("Enabled", justify="center")
|
|
245
|
+
table.add_column("Keywords")
|
|
246
|
+
table.add_column("Extensions")
|
|
247
|
+
table.add_column("Destination")
|
|
248
|
+
|
|
249
|
+
for r in rules:
|
|
250
|
+
keywords = ", ".join(r.keywords) if getattr(r, "keywords", None) else ""
|
|
251
|
+
exts = ", ".join(r.extensions) if getattr(r, "extensions", None) else ""
|
|
252
|
+
enabled_str = "yes" if getattr(r, "enabled", True) else "no"
|
|
253
|
+
dest = safe_str(getattr(r, "destination", ""))
|
|
254
|
+
table.add_row(
|
|
255
|
+
safe_str(r.name),
|
|
256
|
+
str(r.priority),
|
|
257
|
+
enabled_str,
|
|
258
|
+
keywords,
|
|
259
|
+
exts,
|
|
260
|
+
dest,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
console.print(table)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def render_rule_trace(
|
|
267
|
+
filename: str,
|
|
268
|
+
ext: str,
|
|
269
|
+
size: int,
|
|
270
|
+
raw_toks: Sequence[str],
|
|
271
|
+
camel_toks: Sequence[str],
|
|
272
|
+
traces: Sequence[RuleTrace],
|
|
273
|
+
decision_kind: DecisionKind,
|
|
274
|
+
hit_rule: str | None,
|
|
275
|
+
hit_dest: str | None,
|
|
276
|
+
hit_reason: str,
|
|
277
|
+
*,
|
|
278
|
+
no_color: bool = False,
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Render the explanation output for rules test command."""
|
|
281
|
+
console = get_console(no_color=no_color)
|
|
282
|
+
console.print(f"File: {filename} (ext={ext}, {size} bytes)")
|
|
283
|
+
console.print(f"tokens(raw) : {' '.join(raw_toks)}")
|
|
284
|
+
console.print(f"tokens(camel) : {' '.join(camel_toks)}\n")
|
|
285
|
+
|
|
286
|
+
for t in traces:
|
|
287
|
+
mark = "✓" if t.matched else "✗"
|
|
288
|
+
reasons_text = (
|
|
289
|
+
", ".join(t.reasons) if t.reasons else ("matched" if t.matched else "no match")
|
|
290
|
+
)
|
|
291
|
+
rule_name = safe_str(t.rule_name)
|
|
292
|
+
if no_color:
|
|
293
|
+
console.print(f" {mark} {rule_name:<16} priority {t.priority:<4} {reasons_text}")
|
|
294
|
+
elif t.matched:
|
|
295
|
+
console.print(
|
|
296
|
+
f" [green]{mark}[/] [bold]{rule_name:<16}[/] "
|
|
297
|
+
f"priority {t.priority:<4} {reasons_text}"
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
console.print(
|
|
301
|
+
f" [dim red]{mark}[/] [dim]{rule_name:<16}[/] "
|
|
302
|
+
f"[dim]priority {t.priority:<4} {reasons_text}[/]"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
console.print()
|
|
306
|
+
if decision_kind == DecisionKind.MATCH:
|
|
307
|
+
dest_text = f" -> {hit_dest}" if hit_dest else ""
|
|
308
|
+
match_line = f"Result: MATCH {hit_rule}{dest_text}"
|
|
309
|
+
console.print(f"[green bold]{match_line}[/]" if not no_color else match_line)
|
|
310
|
+
elif decision_kind == DecisionKind.AMBIGUOUS:
|
|
311
|
+
ambig_line = f"Result: AMBIGUOUS {hit_reason}"
|
|
312
|
+
console.print(f"[yellow bold]{ambig_line}[/]" if not no_color else ambig_line)
|
|
313
|
+
else:
|
|
314
|
+
nomatch_line = f"Result: NO_MATCH {hit_reason or 'no rule matched'}"
|
|
315
|
+
console.print(f"[dim]{nomatch_line}[/]" if not no_color else nomatch_line)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def render_history_table(
|
|
319
|
+
records: Sequence[OperationRecord],
|
|
320
|
+
*,
|
|
321
|
+
no_color: bool = False,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Render table of history operations."""
|
|
324
|
+
console = get_console(no_color=no_color)
|
|
325
|
+
table = Table(title="History Operations", header_style="bold")
|
|
326
|
+
table.add_column("ID", justify="right")
|
|
327
|
+
table.add_column("Batch", justify="right")
|
|
328
|
+
table.add_column("Status")
|
|
329
|
+
table.add_column("Rule")
|
|
330
|
+
table.add_column("Source")
|
|
331
|
+
table.add_column("Destination")
|
|
332
|
+
table.add_column("Created At")
|
|
333
|
+
|
|
334
|
+
for rec in records:
|
|
335
|
+
status_str = rec.status.value
|
|
336
|
+
table.add_row(
|
|
337
|
+
str(rec.id),
|
|
338
|
+
str(rec.batch_id),
|
|
339
|
+
status_str,
|
|
340
|
+
safe_str(rec.rule_name or ""),
|
|
341
|
+
safe_str(rec.src_path),
|
|
342
|
+
safe_str(rec.dst_path or ""),
|
|
343
|
+
safe_str(rec.created_at[:19]),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
console.print(table)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def render_doctor_results(
|
|
350
|
+
checks: list[dict[str, Any]],
|
|
351
|
+
fixes: list[dict[str, Any]],
|
|
352
|
+
*,
|
|
353
|
+
no_color: bool = False,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Render doctor checklist and fixes applied."""
|
|
356
|
+
console = get_console(no_color=no_color)
|
|
357
|
+
console.print("[bold]ClutterXP Doctor Diagnostics[/bold]\n")
|
|
358
|
+
|
|
359
|
+
for c in checks:
|
|
360
|
+
status = c.get("status", "ok")
|
|
361
|
+
name = c.get("name", "")
|
|
362
|
+
message = c.get("message", "")
|
|
363
|
+
if status == "ok":
|
|
364
|
+
mark = "[green]✓[/green]" if not no_color else "✓"
|
|
365
|
+
console.print(f" {mark} {name}: {message}")
|
|
366
|
+
elif status == "warning":
|
|
367
|
+
mark = "[yellow]![/yellow]" if not no_color else "!"
|
|
368
|
+
console.print(f" {mark} {name}: {message}")
|
|
369
|
+
else: # error
|
|
370
|
+
mark = "[red]✗[/red]" if not no_color else "✗"
|
|
371
|
+
console.print(f" {mark} {name}: {message}")
|
|
372
|
+
|
|
373
|
+
if fixes:
|
|
374
|
+
console.print("\n[bold]Fixes applied:[/bold]")
|
|
375
|
+
for f in fixes:
|
|
376
|
+
console.print(f" - {f.get('check')}: {f.get('message')}")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
__all__ = [
|
|
380
|
+
"get_console",
|
|
381
|
+
"print_json",
|
|
382
|
+
"progress_bar",
|
|
383
|
+
"render_doctor_results",
|
|
384
|
+
"render_history_table",
|
|
385
|
+
"render_organize_summary",
|
|
386
|
+
"render_plan",
|
|
387
|
+
"render_rule_trace",
|
|
388
|
+
"render_rules_table",
|
|
389
|
+
"safe_str",
|
|
390
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration: schema (frozen field names/types), loader, writer, validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .jsonschema import config_json_schema
|
|
6
|
+
from .loader import load
|
|
7
|
+
from .placeholders import ALLOWED_PLACEHOLDERS, extract_static_root, validate_destination_template
|
|
8
|
+
from .runtime import RuntimeConfig
|
|
9
|
+
from .schema import Conditions, Config, HistorySettings, RuleSpec, Settings, WatchEntry
|
|
10
|
+
from .validate import validate
|
|
11
|
+
from .writer import (
|
|
12
|
+
add_rule,
|
|
13
|
+
disable_rule,
|
|
14
|
+
edit_rule,
|
|
15
|
+
enable_rule,
|
|
16
|
+
init_config,
|
|
17
|
+
remove_rule,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ALLOWED_PLACEHOLDERS",
|
|
22
|
+
"Conditions",
|
|
23
|
+
"Config",
|
|
24
|
+
"HistorySettings",
|
|
25
|
+
"RuleSpec",
|
|
26
|
+
"RuntimeConfig",
|
|
27
|
+
"Settings",
|
|
28
|
+
"WatchEntry",
|
|
29
|
+
"add_rule",
|
|
30
|
+
"config_json_schema",
|
|
31
|
+
"disable_rule",
|
|
32
|
+
"edit_rule",
|
|
33
|
+
"enable_rule",
|
|
34
|
+
"extract_static_root",
|
|
35
|
+
"init_config",
|
|
36
|
+
"load",
|
|
37
|
+
"remove_rule",
|
|
38
|
+
"validate",
|
|
39
|
+
"validate_destination_template",
|
|
40
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""JSON Schema generator for clutterxp configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .schema import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def config_json_schema() -> dict[str, Any]:
|
|
11
|
+
"""Generate and return JSON schema dictionary for Config."""
|
|
12
|
+
return Config.model_json_schema()
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Configuration loader with Pydantic validation, error aggregation, and path expansion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import difflib
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
from ruamel.yaml import YAML
|
|
13
|
+
from ruamel.yaml.error import YAMLError
|
|
14
|
+
|
|
15
|
+
from ..models import ConfigError
|
|
16
|
+
from ..paths import expand
|
|
17
|
+
from .placeholders import validate_destination_template
|
|
18
|
+
from .runtime import RuntimeConfig
|
|
19
|
+
from .schema import Conditions, Config, HistorySettings, RuleSpec, Settings, WatchEntry
|
|
20
|
+
from .validate import validate
|
|
21
|
+
|
|
22
|
+
MigrationFn = Callable[[dict[str, Any]], dict[str, Any]]
|
|
23
|
+
_MIGRATION_REGISTRY: dict[int, MigrationFn] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_migration(from_version: int, fn: MigrationFn) -> None:
|
|
27
|
+
"""Register a migration function from version N to N+1."""
|
|
28
|
+
_MIGRATION_REGISTRY[from_version] = fn
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_migrations(data: dict[str, Any], target_version: int = 1) -> dict[str, Any]:
|
|
32
|
+
"""Apply sequential migrations up to target_version."""
|
|
33
|
+
current_v = data.get("version", 1)
|
|
34
|
+
if not isinstance(current_v, int):
|
|
35
|
+
return data
|
|
36
|
+
while current_v < target_version:
|
|
37
|
+
if current_v not in _MIGRATION_REGISTRY:
|
|
38
|
+
raise ConfigError(
|
|
39
|
+
f"No migration registered from config version {current_v} to {current_v + 1}"
|
|
40
|
+
)
|
|
41
|
+
data = _MIGRATION_REGISTRY[current_v](data)
|
|
42
|
+
current_v += 1
|
|
43
|
+
data["version"] = current_v
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_loc(loc: tuple[int | str, ...]) -> str:
|
|
48
|
+
"""Format Pydantic error loc into a YAML-style path (e.g. rules[0].destination)."""
|
|
49
|
+
parts: list[str] = []
|
|
50
|
+
for item in loc:
|
|
51
|
+
if isinstance(item, int):
|
|
52
|
+
parts.append(f"[{item}]")
|
|
53
|
+
else:
|
|
54
|
+
if parts:
|
|
55
|
+
parts.append(f".{item}")
|
|
56
|
+
else:
|
|
57
|
+
parts.append(str(item))
|
|
58
|
+
return "".join(parts)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_allowed_keys_for_loc(parent_loc: tuple[int | str, ...]) -> list[str]:
|
|
62
|
+
"""Return valid field names for the model at the given loc prefix."""
|
|
63
|
+
if not parent_loc:
|
|
64
|
+
return list(Config.model_fields.keys())
|
|
65
|
+
if parent_loc[0] == "settings":
|
|
66
|
+
if len(parent_loc) > 1 and parent_loc[1] == "history":
|
|
67
|
+
return list(HistorySettings.model_fields.keys())
|
|
68
|
+
return list(Settings.model_fields.keys())
|
|
69
|
+
if parent_loc[0] == "rules":
|
|
70
|
+
if len(parent_loc) > 2 and parent_loc[2] == "conditions":
|
|
71
|
+
return list(Conditions.model_fields.keys())
|
|
72
|
+
return list(RuleSpec.model_fields.keys())
|
|
73
|
+
if parent_loc[0] == "watch":
|
|
74
|
+
return list(WatchEntry.model_fields.keys())
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load(path: Path) -> RuntimeConfig:
|
|
79
|
+
"""Load, parse, validate, and expand configuration from path.
|
|
80
|
+
|
|
81
|
+
Returns a RuntimeConfig with accumulated errors and warnings.
|
|
82
|
+
Raises ConfigError only for fatal parse or file access errors.
|
|
83
|
+
"""
|
|
84
|
+
if not path.exists():
|
|
85
|
+
raise ConfigError(f"Config file not found: {path}")
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
raise ConfigError(f"Config path is not a file: {path}")
|
|
88
|
+
|
|
89
|
+
yaml = YAML(typ="safe")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with path.open("r", encoding="utf-8") as f:
|
|
93
|
+
raw_data = yaml.load(f)
|
|
94
|
+
except YAMLError as e:
|
|
95
|
+
raise ConfigError(f"YAML parse error in '{path}': {e}") from e
|
|
96
|
+
except OSError as e:
|
|
97
|
+
raise ConfigError(f"Failed to read config file '{path}': {e}") from e
|
|
98
|
+
|
|
99
|
+
if raw_data is None:
|
|
100
|
+
raw_data = {}
|
|
101
|
+
|
|
102
|
+
if not isinstance(raw_data, dict):
|
|
103
|
+
raise ConfigError(
|
|
104
|
+
f"Config root in '{path}' must be a YAML mapping, got {type(raw_data).__name__}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
errors: list[str] = []
|
|
108
|
+
warnings: list[str] = []
|
|
109
|
+
|
|
110
|
+
# Check version
|
|
111
|
+
raw_version = raw_data.get("version")
|
|
112
|
+
if raw_version is not None and isinstance(raw_version, int) and raw_version > 1:
|
|
113
|
+
errors.append(
|
|
114
|
+
f"version: config version {raw_version} is newer than supported (1); upgrade ClutterXP"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Schema validation with Pydantic (collects all schema errors)
|
|
118
|
+
config_obj: Config | None = None
|
|
119
|
+
try:
|
|
120
|
+
config_obj = Config.model_validate(raw_data)
|
|
121
|
+
except ValidationError as ve:
|
|
122
|
+
for err in ve.errors():
|
|
123
|
+
loc = err["loc"]
|
|
124
|
+
yaml_path = _format_loc(loc)
|
|
125
|
+
msg = err["msg"]
|
|
126
|
+
if err.get("type") == "extra_forbidden":
|
|
127
|
+
unknown = str(loc[-1]) if loc else ""
|
|
128
|
+
allowed = _get_allowed_keys_for_loc(loc[:-1])
|
|
129
|
+
matches = difflib.get_close_matches(unknown, allowed)
|
|
130
|
+
if matches:
|
|
131
|
+
errors.append(
|
|
132
|
+
f"{yaml_path}: unknown key '{unknown}'; did you mean '{matches[0]}'?"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
errors.append(f"{yaml_path}: unknown key '{unknown}'")
|
|
136
|
+
else:
|
|
137
|
+
errors.append(f"{yaml_path}: {msg}")
|
|
138
|
+
|
|
139
|
+
# Check template placeholders across raw rules to report all problems at once
|
|
140
|
+
raw_rules = raw_data.get("rules", [])
|
|
141
|
+
if isinstance(raw_rules, list):
|
|
142
|
+
for i, r in enumerate(raw_rules):
|
|
143
|
+
if isinstance(r, dict) and isinstance(r.get("destination"), str):
|
|
144
|
+
dest_str = r["destination"]
|
|
145
|
+
if "$" in dest_str or dest_str.startswith("~"):
|
|
146
|
+
with contextlib.suppress(ConfigError):
|
|
147
|
+
dest_str = str(expand(dest_str))
|
|
148
|
+
p_errors = validate_destination_template(dest_str)
|
|
149
|
+
for pe in p_errors:
|
|
150
|
+
err_msg = f"rules[{i}].destination: {pe}"
|
|
151
|
+
if err_msg not in errors:
|
|
152
|
+
errors.append(err_msg)
|
|
153
|
+
|
|
154
|
+
# Further validation if schema parsed successfully
|
|
155
|
+
if config_obj is not None:
|
|
156
|
+
# 1. Expand paths in destinations and watch entries
|
|
157
|
+
expanded_rules: list[RuleSpec] = []
|
|
158
|
+
for i, rule in enumerate(config_obj.rules):
|
|
159
|
+
if "$" in rule.destination or rule.destination.startswith("~"):
|
|
160
|
+
try:
|
|
161
|
+
expanded_dest = str(expand(rule.destination))
|
|
162
|
+
rule = rule.model_copy(update={"destination": expanded_dest})
|
|
163
|
+
except ConfigError as ce:
|
|
164
|
+
err_msg = f"rules[{i}].destination: {ce.message}"
|
|
165
|
+
if err_msg not in errors:
|
|
166
|
+
errors.append(err_msg)
|
|
167
|
+
expanded_rules.append(rule)
|
|
168
|
+
|
|
169
|
+
expanded_watch: list[WatchEntry] = []
|
|
170
|
+
for i, entry in enumerate(config_obj.watch):
|
|
171
|
+
try:
|
|
172
|
+
expanded_p = str(expand(entry.path))
|
|
173
|
+
entry = entry.model_copy(update={"path": expanded_p})
|
|
174
|
+
except ConfigError as ce:
|
|
175
|
+
err_msg = f"watch[{i}].path: {ce.message}"
|
|
176
|
+
if err_msg not in errors:
|
|
177
|
+
errors.append(err_msg)
|
|
178
|
+
expanded_watch.append(entry)
|
|
179
|
+
|
|
180
|
+
# 2. Cross-config checks
|
|
181
|
+
updated_config = config_obj.model_copy(
|
|
182
|
+
update={"rules": expanded_rules, "watch": expanded_watch}
|
|
183
|
+
)
|
|
184
|
+
cross_errors, cross_warnings = validate(updated_config)
|
|
185
|
+
errors.extend(cross_errors)
|
|
186
|
+
warnings.extend(cross_warnings)
|
|
187
|
+
|
|
188
|
+
enabled_rules = tuple(r for r in expanded_rules if r.enabled)
|
|
189
|
+
disabled_rules = tuple(r for r in expanded_rules if not r.enabled)
|
|
190
|
+
watch_entries = tuple(expanded_watch)
|
|
191
|
+
settings = config_obj.settings
|
|
192
|
+
else:
|
|
193
|
+
enabled_rules = ()
|
|
194
|
+
disabled_rules = ()
|
|
195
|
+
watch_entries = ()
|
|
196
|
+
settings = Settings()
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
200
|
+
except OSError:
|
|
201
|
+
mtime_ns = 0
|
|
202
|
+
|
|
203
|
+
return RuntimeConfig(
|
|
204
|
+
config_path=path,
|
|
205
|
+
mtime_ns=mtime_ns,
|
|
206
|
+
settings=settings,
|
|
207
|
+
watch_entries=watch_entries,
|
|
208
|
+
rules=enabled_rules,
|
|
209
|
+
disabled_rules=disabled_rules,
|
|
210
|
+
errors=tuple(errors),
|
|
211
|
+
warnings=tuple(warnings),
|
|
212
|
+
)
|