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,79 @@
|
|
|
1
|
+
"""Allowed destination template placeholders and validator (docs/02_RULE_ENGINE.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
ALLOWED_PLACEHOLDERS: frozenset[str] = frozenset(
|
|
9
|
+
[
|
|
10
|
+
"year",
|
|
11
|
+
"month",
|
|
12
|
+
"day",
|
|
13
|
+
"ext",
|
|
14
|
+
"rule",
|
|
15
|
+
"mtime",
|
|
16
|
+
]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def extract_static_root(template: str) -> str:
|
|
21
|
+
"""Extract the path portion before the first component containing a placeholder."""
|
|
22
|
+
parts = template.split("/")
|
|
23
|
+
static_parts: list[str] = []
|
|
24
|
+
for part in parts:
|
|
25
|
+
if "{" in part:
|
|
26
|
+
break
|
|
27
|
+
static_parts.append(part)
|
|
28
|
+
|
|
29
|
+
if not static_parts or (len(static_parts) == 1 and static_parts[0] == ""):
|
|
30
|
+
return "/" if template.startswith("/") else "."
|
|
31
|
+
|
|
32
|
+
res = "/".join(static_parts)
|
|
33
|
+
return res if res else ("/" if template.startswith("/") else ".")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_destination_template(template: str) -> list[str]:
|
|
37
|
+
"""Validate placeholder names and formats in a destination template string.
|
|
38
|
+
|
|
39
|
+
Returns a list of error descriptions (empty if valid).
|
|
40
|
+
"""
|
|
41
|
+
errors: list[str] = []
|
|
42
|
+
|
|
43
|
+
# Check for unmatched brackets
|
|
44
|
+
open_count = template.count("{")
|
|
45
|
+
close_count = template.count("}")
|
|
46
|
+
if open_count != close_count:
|
|
47
|
+
errors.append(f"Unmatched brackets in destination template '{template}'")
|
|
48
|
+
return errors
|
|
49
|
+
|
|
50
|
+
# Check for empty placeholder {}
|
|
51
|
+
if "{}" in template:
|
|
52
|
+
errors.append("Empty placeholder '{}' in destination template")
|
|
53
|
+
|
|
54
|
+
# Find all placeholder contents
|
|
55
|
+
placeholders = re.findall(r"\{([^}]*)\}", template)
|
|
56
|
+
for p in placeholders:
|
|
57
|
+
if not p:
|
|
58
|
+
continue
|
|
59
|
+
if p.startswith("mtime:"):
|
|
60
|
+
fmt = p[6:]
|
|
61
|
+
if not fmt:
|
|
62
|
+
errors.append("Empty mtime format in destination template")
|
|
63
|
+
elif "/" in fmt or "\x00" in fmt:
|
|
64
|
+
errors.append(
|
|
65
|
+
f"mtime format '{fmt}' in destination template cannot contain '/' or NUL"
|
|
66
|
+
)
|
|
67
|
+
elif p in ALLOWED_PLACEHOLDERS:
|
|
68
|
+
continue
|
|
69
|
+
else:
|
|
70
|
+
close = difflib.get_close_matches(p, sorted(ALLOWED_PLACEHOLDERS))
|
|
71
|
+
if close:
|
|
72
|
+
errors.append(
|
|
73
|
+
f"Unknown placeholder '{{{p}}}' in destination template; "
|
|
74
|
+
f"did you mean '{{{close[0]}}}'?"
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
errors.append(f"Unknown placeholder '{{{p}}}' in destination template")
|
|
78
|
+
|
|
79
|
+
return errors
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Runtime configuration representation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .schema import RuleSpec, Settings, WatchEntry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RuntimeConfig:
|
|
13
|
+
config_path: Path
|
|
14
|
+
mtime_ns: int # config file mtime for reload detection
|
|
15
|
+
settings: Settings # from schema.py
|
|
16
|
+
watch_entries: tuple[WatchEntry, ...] # from schema.py
|
|
17
|
+
rules: tuple[RuleSpec, ...] # enabled rules only
|
|
18
|
+
disabled_rules: tuple[RuleSpec, ...] # disabled rules
|
|
19
|
+
errors: tuple[str, ...] # config errors (empty = valid)
|
|
20
|
+
warnings: tuple[str, ...] # config warnings
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_valid(self) -> bool:
|
|
24
|
+
"""True if configuration has no errors."""
|
|
25
|
+
return len(self.errors) == 0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def all_rules(self) -> tuple[RuleSpec, ...]:
|
|
29
|
+
"""All rules including disabled ones."""
|
|
30
|
+
return self.rules + self.disabled_rules
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Pydantic schema for config.yaml (field names and types are FROZEN).
|
|
2
|
+
|
|
3
|
+
Agents may ADD validators and error messages here, but must not rename, remove or retype a
|
|
4
|
+
field without a CONTRACT_CHANGE request. Cross-config checks (loops, denylist, overlaps)
|
|
5
|
+
live in config/validate.py, not here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
13
|
+
|
|
14
|
+
from ..models import CollisionPolicy, CreateDestinations, MatchMode
|
|
15
|
+
from ..units import parse_duration_seconds, parse_size
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Base(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HistorySettings(_Base):
|
|
23
|
+
retention_days: int = Field(365, ge=0)
|
|
24
|
+
log_no_match: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Settings(_Base):
|
|
28
|
+
collision: CollisionPolicy = CollisionPolicy.SKIP
|
|
29
|
+
create_destinations: CreateDestinations = CreateDestinations.LEAF
|
|
30
|
+
match: MatchMode = MatchMode.TOKEN
|
|
31
|
+
case_sensitive: bool = False
|
|
32
|
+
skip_hidden: bool = True
|
|
33
|
+
follow_symlinks: bool = False
|
|
34
|
+
confirm_threshold: int = Field(10, ge=0)
|
|
35
|
+
settle_seconds: float = Field(3.0, ge=0, le=3600)
|
|
36
|
+
verify_copy: Literal["size", "hash"] = "size"
|
|
37
|
+
record_hash: bool = False
|
|
38
|
+
history: HistorySettings = Field(default_factory=HistorySettings)
|
|
39
|
+
ignore: list[str] = Field(default_factory=list)
|
|
40
|
+
allow_chain: bool = False
|
|
41
|
+
|
|
42
|
+
@field_validator("match")
|
|
43
|
+
@classmethod
|
|
44
|
+
def _plain_modes_only(cls, v: MatchMode) -> MatchMode:
|
|
45
|
+
if v in (MatchMode.GLOB, MatchMode.REGEX):
|
|
46
|
+
raise ValueError("use a 'glob:' or 'regex:' prefix on a keyword instead of this mode")
|
|
47
|
+
return v
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WatchEntry(_Base):
|
|
51
|
+
path: str = Field(min_length=1)
|
|
52
|
+
recursive: bool = False
|
|
53
|
+
settle_seconds: float | None = Field(None, ge=0, le=3600)
|
|
54
|
+
ignore: list[str] = Field(default_factory=list)
|
|
55
|
+
rules: Literal["all"] | list[str] = "all"
|
|
56
|
+
initial_scan: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Conditions(_Base):
|
|
60
|
+
min_size: int | str | None = None
|
|
61
|
+
max_size: int | str | None = None
|
|
62
|
+
older_than: int | str | None = None
|
|
63
|
+
newer_than: int | str | None = None
|
|
64
|
+
|
|
65
|
+
@field_validator("min_size", "max_size")
|
|
66
|
+
@classmethod
|
|
67
|
+
def _size(cls, v: int | str | None) -> int | str | None:
|
|
68
|
+
if v is not None:
|
|
69
|
+
parse_size(v) # raises ValueError -> reported by pydantic
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
@field_validator("older_than", "newer_than")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _duration(cls, v: int | str | None) -> int | str | None:
|
|
75
|
+
if v is not None:
|
|
76
|
+
parse_duration_seconds(v)
|
|
77
|
+
return v
|
|
78
|
+
|
|
79
|
+
@model_validator(mode="after")
|
|
80
|
+
def _ranges(self) -> "Conditions":
|
|
81
|
+
if self.min_size is not None and self.max_size is not None:
|
|
82
|
+
if parse_size(self.min_size) > parse_size(self.max_size):
|
|
83
|
+
raise ValueError("min_size is larger than max_size")
|
|
84
|
+
if self.older_than is not None and self.newer_than is not None:
|
|
85
|
+
if parse_duration_seconds(self.older_than) >= parse_duration_seconds(self.newer_than):
|
|
86
|
+
raise ValueError("older_than must be less than newer_than, or no file can match")
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def any_set(self) -> bool:
|
|
90
|
+
return any(
|
|
91
|
+
v is not None for v in (self.min_size, self.max_size, self.older_than, self.newer_than)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RuleSpec(_Base):
|
|
96
|
+
name: str = Field(min_length=1)
|
|
97
|
+
enabled: bool = True
|
|
98
|
+
priority: int = Field(100, ge=0, le=100_000)
|
|
99
|
+
destination: str = Field(min_length=1)
|
|
100
|
+
keywords: list[str] = Field(default_factory=list)
|
|
101
|
+
all_of: list[str] = Field(default_factory=list)
|
|
102
|
+
exclude: list[str] = Field(default_factory=list)
|
|
103
|
+
match: MatchMode | None = None # None means "use settings.match"
|
|
104
|
+
extensions: list[str] = Field(default_factory=list)
|
|
105
|
+
conditions: Conditions = Field(default_factory=Conditions)
|
|
106
|
+
collision: CollisionPolicy | None = None
|
|
107
|
+
create_destinations: CreateDestinations | None = None
|
|
108
|
+
|
|
109
|
+
@field_validator("name")
|
|
110
|
+
@classmethod
|
|
111
|
+
def _name(cls, v: str) -> str:
|
|
112
|
+
if "/" in v or "\x00" in v:
|
|
113
|
+
raise ValueError("rule name must not contain '/' or NUL")
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
@field_validator("keywords", "all_of", "exclude")
|
|
117
|
+
@classmethod
|
|
118
|
+
def _non_empty_strings(cls, v: list[str]) -> list[str]:
|
|
119
|
+
out = [s.strip() for s in v]
|
|
120
|
+
if any(not s for s in out):
|
|
121
|
+
raise ValueError("empty keyword")
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
@field_validator("match")
|
|
125
|
+
@classmethod
|
|
126
|
+
def _plain_mode(cls, v: MatchMode | None) -> MatchMode | None:
|
|
127
|
+
if v in (MatchMode.GLOB, MatchMode.REGEX):
|
|
128
|
+
raise ValueError("use a 'glob:' or 'regex:' prefix on a keyword instead of this mode")
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
@field_validator("extensions")
|
|
132
|
+
@classmethod
|
|
133
|
+
def _extensions(cls, v: list[str]) -> list[str]:
|
|
134
|
+
out: list[str] = []
|
|
135
|
+
for e in v:
|
|
136
|
+
e = e.strip().lstrip(".").lower()
|
|
137
|
+
if not e or "/" in e:
|
|
138
|
+
raise ValueError(f"invalid extension {e!r}")
|
|
139
|
+
out.append(e)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
@model_validator(mode="after")
|
|
143
|
+
def _needs_positive_criterion(self) -> "RuleSpec":
|
|
144
|
+
if not (self.keywords or self.all_of or self.extensions or self.conditions.any_set()):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"rule needs at least one of: keywords, all_of, extensions, conditions "
|
|
147
|
+
"(a rule with only 'exclude' would match every file)"
|
|
148
|
+
)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Config(_Base):
|
|
153
|
+
version: Literal[1]
|
|
154
|
+
settings: Settings = Field(default_factory=Settings)
|
|
155
|
+
watch: list[WatchEntry] = Field(default_factory=list)
|
|
156
|
+
rules: list[RuleSpec] = Field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
@model_validator(mode="after")
|
|
159
|
+
def _unique_rule_names(self) -> "Config":
|
|
160
|
+
seen: dict[str, str] = {}
|
|
161
|
+
for r in self.rules:
|
|
162
|
+
key = r.name.casefold()
|
|
163
|
+
if key in seen:
|
|
164
|
+
raise ValueError(f"duplicate rule name {r.name!r} (also {seen[key]!r})")
|
|
165
|
+
seen[key] = r.name
|
|
166
|
+
return self
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Cross-configuration validation (docs/03_CONFIG.md section 4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..models import CreateDestinations, MatchMode
|
|
10
|
+
from ..paths import expand
|
|
11
|
+
from ..safety import is_denylisted
|
|
12
|
+
from .placeholders import extract_static_root
|
|
13
|
+
from .schema import Config, RuleSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate(config: Config, *, warn_only: bool = False) -> tuple[list[str], list[str]]:
|
|
17
|
+
"""Perform cross-configuration validation on a Config model.
|
|
18
|
+
|
|
19
|
+
Checks:
|
|
20
|
+
- Errors:
|
|
21
|
+
1. Destination equals or is inside a watched folder (unless allow_chain is True).
|
|
22
|
+
2. Watch folder or destination is in the system denylist or contains hidden component.
|
|
23
|
+
3. Recursive watch of $HOME is rejected.
|
|
24
|
+
4. Per-folder rules list references unknown rules.
|
|
25
|
+
- Warnings:
|
|
26
|
+
1. Watch folder does not exist.
|
|
27
|
+
2. Two enabled rules share keyword at same priority with different destinations.
|
|
28
|
+
3. Substring keyword shorter than 4 characters.
|
|
29
|
+
4. Destination static root does not exist when create_destinations is 'never'.
|
|
30
|
+
5. Destination on a different filesystem than a watched folder.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
(errors, warnings) lists of human-readable issues with YAML paths.
|
|
34
|
+
"""
|
|
35
|
+
errors: list[str] = []
|
|
36
|
+
warnings: list[str] = []
|
|
37
|
+
|
|
38
|
+
# 1. Expand paths and check watch folders
|
|
39
|
+
expanded_watch_paths: dict[int, Path] = {}
|
|
40
|
+
for w_idx, w in enumerate(config.watch):
|
|
41
|
+
try:
|
|
42
|
+
w_path = expand(w.path)
|
|
43
|
+
expanded_watch_paths[w_idx] = w_path
|
|
44
|
+
except Exception as e:
|
|
45
|
+
errors.append(f"watch[{w_idx}].path: failed to expand path '{w.path}': {e}")
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
# Denylist check for watch folder
|
|
49
|
+
if is_denylisted(w_path):
|
|
50
|
+
errors.append(f"watch[{w_idx}].path: path '{w.path}' is in the system denylist")
|
|
51
|
+
|
|
52
|
+
# Recursive $HOME check
|
|
53
|
+
try:
|
|
54
|
+
home = Path(os.path.realpath(str(Path.home())))
|
|
55
|
+
real_w = Path(os.path.realpath(str(w_path)))
|
|
56
|
+
if w.recursive and real_w == home:
|
|
57
|
+
errors.append(
|
|
58
|
+
f"watch[{w_idx}].path: recursive watch of $HOME ('{w.path}') "
|
|
59
|
+
"is refused for safety"
|
|
60
|
+
)
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Watch folder existence check (warning for config validate)
|
|
65
|
+
if not w_path.exists():
|
|
66
|
+
warnings.append(f"watch[{w_idx}].path: watched folder '{w.path}' does not exist")
|
|
67
|
+
|
|
68
|
+
real_watch_paths: dict[int, Path] = {
|
|
69
|
+
w_idx: Path(os.path.realpath(str(w_p))) for w_idx, w_p in expanded_watch_paths.items()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# 2. Check rule destinations (denylist, hidden components, loop guard)
|
|
73
|
+
expanded_rule_roots: dict[int, Path] = {}
|
|
74
|
+
for r_idx, r in enumerate(config.rules):
|
|
75
|
+
static_root_str = extract_static_root(r.destination)
|
|
76
|
+
try:
|
|
77
|
+
dest_root = expand(static_root_str)
|
|
78
|
+
expanded_rule_roots[r_idx] = dest_root
|
|
79
|
+
except Exception as e:
|
|
80
|
+
errors.append(
|
|
81
|
+
f"rules[{r_idx}].destination: failed to expand destination '{r.destination}': {e}"
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# System denylist check
|
|
86
|
+
if is_denylisted(dest_root):
|
|
87
|
+
errors.append(
|
|
88
|
+
f"rules[{r_idx}].destination: destination '{r.destination}' "
|
|
89
|
+
"is in the system denylist"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Hidden component check in static root path
|
|
93
|
+
for part in dest_root.parts:
|
|
94
|
+
if part in ("/", "\\"):
|
|
95
|
+
continue
|
|
96
|
+
if part.startswith("."):
|
|
97
|
+
errors.append(
|
|
98
|
+
f"rules[{r_idx}].destination: destination '{r.destination}' "
|
|
99
|
+
f"contains hidden component '{part}'"
|
|
100
|
+
)
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
real_dest = Path(os.path.realpath(str(dest_root)))
|
|
104
|
+
for part in real_dest.parts:
|
|
105
|
+
if part in ("/", "\\"):
|
|
106
|
+
continue
|
|
107
|
+
if part.startswith("."):
|
|
108
|
+
errors.append(
|
|
109
|
+
f"rules[{r_idx}].destination: destination '{r.destination}' "
|
|
110
|
+
f"resolves to hidden component '{part}' in '{real_dest}'"
|
|
111
|
+
)
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
# Infinite loop guard: destination equals or is inside a watched folder
|
|
115
|
+
if not config.settings.allow_chain:
|
|
116
|
+
for w_idx, w_real in real_watch_paths.items():
|
|
117
|
+
if dest_real_in_watch(dest_real=real_dest, watch_real=w_real):
|
|
118
|
+
errors.append(
|
|
119
|
+
f"rules[{r_idx}].destination: destination '{r.destination}' is inside "
|
|
120
|
+
f"watched folder '{config.watch[w_idx].path}' "
|
|
121
|
+
"(settings.allow_chain is false)"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# 3. Per-folder rules list references unknown rules
|
|
125
|
+
known_rule_names = {r.name for r in config.rules}
|
|
126
|
+
for w_idx, w in enumerate(config.watch):
|
|
127
|
+
if isinstance(w.rules, list):
|
|
128
|
+
for rname in w.rules:
|
|
129
|
+
if rname not in known_rule_names:
|
|
130
|
+
close = difflib.get_close_matches(rname, sorted(known_rule_names))
|
|
131
|
+
hint = f"; did you mean '{close[0]}'?" if close else ""
|
|
132
|
+
errors.append(f"watch[{w_idx}].rules: unknown rule '{rname}'{hint}")
|
|
133
|
+
|
|
134
|
+
# 4. Overlapping keywords at same priority with different destinations
|
|
135
|
+
by_priority: dict[int, list[RuleSpec]] = {}
|
|
136
|
+
for r in config.rules:
|
|
137
|
+
if r.enabled:
|
|
138
|
+
by_priority.setdefault(r.priority, []).append(r)
|
|
139
|
+
|
|
140
|
+
for priority_rules in by_priority.values():
|
|
141
|
+
if len(priority_rules) < 2:
|
|
142
|
+
continue
|
|
143
|
+
for i in range(len(priority_rules)):
|
|
144
|
+
for j in range(i + 1, len(priority_rules)):
|
|
145
|
+
r1 = priority_rules[i]
|
|
146
|
+
r2 = priority_rules[j]
|
|
147
|
+
if r1.destination != r2.destination:
|
|
148
|
+
kws1 = {
|
|
149
|
+
k.casefold() for k in r1.keywords if not k.startswith(("glob:", "regex:"))
|
|
150
|
+
}
|
|
151
|
+
kws2 = {
|
|
152
|
+
k.casefold() for k in r2.keywords if not k.startswith(("glob:", "regex:"))
|
|
153
|
+
}
|
|
154
|
+
shared = kws1 & kws2
|
|
155
|
+
for kw in sorted(shared):
|
|
156
|
+
warnings.append(
|
|
157
|
+
f"rules: enabled rules '{r1.name}' and '{r2.name}' "
|
|
158
|
+
f"share keyword '{kw}' at priority {r1.priority} "
|
|
159
|
+
f"with different destinations "
|
|
160
|
+
f"('{r1.destination}' vs '{r2.destination}')"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# 5. Substring keyword < 4 chars
|
|
164
|
+
for r_idx, r in enumerate(config.rules):
|
|
165
|
+
rule_mode = r.match or config.settings.match
|
|
166
|
+
if rule_mode == MatchMode.SUBSTRING:
|
|
167
|
+
for kw in r.keywords:
|
|
168
|
+
if not kw.startswith(("glob:", "regex:")) and len(kw) < 4:
|
|
169
|
+
warnings.append(
|
|
170
|
+
f"rules[{r_idx}].keywords: substring keyword '{kw}' is shorter than "
|
|
171
|
+
"4 characters (risk of false positives)"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# 6. Destination static root missing (when create_destinations is 'never')
|
|
175
|
+
for r_idx, r in enumerate(config.rules):
|
|
176
|
+
create_policy = r.create_destinations or config.settings.create_destinations
|
|
177
|
+
if create_policy == CreateDestinations.NEVER:
|
|
178
|
+
dest_p = expanded_rule_roots.get(r_idx)
|
|
179
|
+
if dest_p is not None and not dest_p.exists():
|
|
180
|
+
warnings.append(
|
|
181
|
+
f"rules[{r_idx}].destination: static root '{dest_p}' does not exist "
|
|
182
|
+
"and create_destinations is 'never'"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# 7. Cross-filesystem warning
|
|
186
|
+
seen_cross_fs: set[tuple[str, str]] = set()
|
|
187
|
+
for w_idx, w_path in expanded_watch_paths.items():
|
|
188
|
+
if not w_path.exists():
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
w_dev = w_path.stat().st_dev
|
|
192
|
+
except OSError:
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
for r_idx, r in enumerate(config.rules):
|
|
196
|
+
if not r.enabled:
|
|
197
|
+
continue
|
|
198
|
+
dest_p = expanded_rule_roots.get(r_idx)
|
|
199
|
+
if dest_p is None:
|
|
200
|
+
continue
|
|
201
|
+
# Traverse parents to find existing ancestor
|
|
202
|
+
check_p = dest_p
|
|
203
|
+
while not check_p.exists() and check_p.parent != check_p:
|
|
204
|
+
check_p = check_p.parent
|
|
205
|
+
if check_p.exists():
|
|
206
|
+
try:
|
|
207
|
+
if check_p.stat().st_dev != w_dev:
|
|
208
|
+
pair = (str(w_path), str(dest_p))
|
|
209
|
+
if pair not in seen_cross_fs:
|
|
210
|
+
seen_cross_fs.add(pair)
|
|
211
|
+
warnings.append(
|
|
212
|
+
f"rules[{r_idx}].destination: destination '{r.destination}' "
|
|
213
|
+
f"is on a different filesystem than watched folder "
|
|
214
|
+
f"'{config.watch[w_idx].path}' (cross-filesystem moves will copy)"
|
|
215
|
+
)
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
if warn_only:
|
|
220
|
+
return ([], errors + warnings)
|
|
221
|
+
return (errors, warnings)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def dest_real_in_watch(*, dest_real: Path, watch_real: Path) -> bool:
|
|
225
|
+
"""Return True if dest_real equals watch_real or is inside watch_real."""
|
|
226
|
+
return dest_real == watch_real or watch_real in dest_real.parents
|