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,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
+ )