gitpro 0.3.1__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.
gitpro/config.py ADDED
@@ -0,0 +1,517 @@
1
+ """Find, load and validate the identities TOML. Reads only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ from dataclasses import MISSING, dataclass, field, fields, replace
9
+ from pathlib import Path
10
+ from typing import Optional, Tuple
11
+
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else:
15
+ import tomli as tomllib
16
+
17
+ _toml = tomllib # the one TOML parser (tomllib, or tomli before 3.11)
18
+
19
+ ENV_VAR = "GITPRO_CONFIG"
20
+ USER_CONFIG = ".config/gitpro.toml" # relative to home
21
+
22
+ # Leading alphanumeric everywhere so no value can be parsed as an ssh/git option.
23
+ _LABEL = r"[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
24
+ HOST_RE = re.compile(rf"^{_LABEL}(?:\.{_LABEL})*$")
25
+ ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
26
+ USER_RE = ID_RE
27
+ OWNER_RE = re.compile(r"^[A-Za-z0-9*][A-Za-z0-9._*-]*(?:/[A-Za-z0-9*][A-Za-z0-9._*-]*)*$")
28
+ PATH_RE = re.compile(r"^(?:~/|/)[A-Za-z0-9._/@+-]+$")
29
+ EMAIL_RE = re.compile(r'^[^@\s<>"]+@[^@\s<>"]+$')
30
+ CONTROL_RE = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
31
+ SCAN_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") # [scan] roots: tab included
32
+ # Reserved for documentation (RFC 2606): never real, so a value left over from the example.
33
+ PLACEHOLDER_RE = re.compile(r"(?:^|\.)(?:example|example\.(?:com|net|org))$", re.I)
34
+
35
+
36
+ class ConfigError(Exception):
37
+ pass
38
+
39
+
40
+ def check(cond: object, msg: str) -> None:
41
+ if not cond:
42
+ raise ConfigError(msg)
43
+
44
+
45
+ class Strings(tuple):
46
+ """A config list: a tuple (hashable, immutable) that still equals a list with the same items,
47
+ so code and tests written when these fields were lists keep working."""
48
+ __slots__ = ()
49
+
50
+ def __eq__(self, other: object) -> bool:
51
+ if isinstance(other, list):
52
+ return list(self) == other
53
+ return tuple.__eq__(self, other)
54
+
55
+ def __ne__(self, other: object) -> bool:
56
+ eq = self.__eq__(other)
57
+ return eq if eq is NotImplemented else not eq
58
+
59
+ __hash__ = tuple.__hash__
60
+
61
+
62
+ def _tuples(obj: object, *names: str) -> None:
63
+ """Turn list values of these fields into Strings (frozen-safe, from __post_init__)."""
64
+ for n in names:
65
+ v = getattr(obj, n)
66
+ if isinstance(v, (list, tuple)) and not isinstance(v, Strings):
67
+ object.__setattr__(obj, n, Strings(v))
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Trailers:
72
+ co_authored_by: Optional[str] = None # "allow" | "forbid" | "require" | None = inherit
73
+ forbid_emails: Optional[Tuple[str, ...]] = None # globs
74
+
75
+ def __post_init__(self) -> None:
76
+ _tuples(self, "forbid_emails")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Default:
81
+ name: Optional[str] = None
82
+ email: Optional[str] = None
83
+ ssh_key: Optional[str] = None
84
+ ssh_hosts: Tuple[str, ...] = ()
85
+ sign: bool = False
86
+ git: dict = field(default_factory=dict, hash=False) # the one mutable field; nobody mutates it
87
+ gh_account: Optional[str] = None
88
+ glab_account: Optional[str] = None
89
+ clone_dir: Optional[str] = None
90
+ trailers: Trailers = Trailers()
91
+
92
+ def __post_init__(self) -> None:
93
+ _tuples(self, "ssh_hosts")
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Identity:
98
+ id: str
99
+ name: str
100
+ email: str
101
+ host: str
102
+ owners: Tuple[str, ...] = ()
103
+ ssh_key: Optional[str] = None
104
+ ssh_alias: Optional[str] = None
105
+ ssh_user: str = "git"
106
+ ssh_port: Optional[int] = None
107
+ rewrite: bool = True
108
+ rewrite_https: bool = False
109
+ sign: Optional[bool] = None # None: inherit [default].sign
110
+ file: Optional[str] = None
111
+ git: dict = field(default_factory=dict, hash=False)
112
+ gh_account: Optional[str] = None
113
+ glab_account: Optional[str] = None
114
+ clone_dir: Optional[str] = None
115
+ policy: Optional[str] = None
116
+ trailers: Trailers = Trailers()
117
+
118
+ def __post_init__(self) -> None:
119
+ _tuples(self, "owners")
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Scan:
124
+ roots: Tuple[str, ...] = ("~",)
125
+ exclude: Tuple[str, ...] = ()
126
+ stale_days: int = 180
127
+
128
+ def __post_init__(self) -> None:
129
+ _tuples(self, "roots", "exclude")
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Guard:
134
+ hooks: bool = False
135
+ on_mismatch: str = "block" # "block" | "warn"
136
+ chain_to: str = ""
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Clone:
141
+ dir: Optional[str] = None
142
+ layout: str = "{host}/{owner}/{repo}"
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Forge:
147
+ online: bool = False
148
+ similar: bool = False
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class Agents:
153
+ hook: str = "block" # "block" | "warn" | "off"
154
+ bot_emails: Tuple[str, ...] = ("*[bot]@users.noreply.github.com", "noreply@anthropic.com")
155
+
156
+ def __post_init__(self) -> None:
157
+ _tuples(self, "bot_emails")
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Policy:
162
+ files: Tuple[str, ...] = ()
163
+
164
+ def __post_init__(self) -> None:
165
+ _tuples(self, "files")
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class Config:
170
+ """Everything in the TOML. Config(default, identities) wraps the old (Default, list[Identity]) pair."""
171
+ default: Default
172
+ identities: Tuple[Identity, ...]
173
+ scan: Scan = Scan()
174
+ guard: Guard = Guard()
175
+ clone: Clone = Clone()
176
+ forge: Forge = Forge()
177
+ agents: Agents = Agents()
178
+ policy: Policy = Policy()
179
+ path: Optional[Path] = None
180
+ how: str = ""
181
+
182
+ def __post_init__(self) -> None:
183
+ if not isinstance(self.identities, tuple):
184
+ object.__setattr__(self, "identities", tuple(self.identities))
185
+
186
+
187
+ def signs(i: Identity, d: Default) -> bool:
188
+ return d.sign if i.sign is None else i.sign
189
+
190
+
191
+ def find(explicit: Path | None, home: Path) -> tuple[Path, str]:
192
+ """Return (config path, where it was found)."""
193
+ if explicit:
194
+ return explicit.expanduser(), "--config"
195
+ if os.environ.get(ENV_VAR):
196
+ return Path(os.environ[ENV_VAR]).expanduser(), f"${ENV_VAR}"
197
+ if (home / USER_CONFIG).is_file():
198
+ return home / USER_CONFIG, "user config"
199
+ raise ConfigError(f"no config found (looked at -c, ${ENV_VAR}, ~/{USER_CONFIG}); "
200
+ "create one with: gitpro init")
201
+
202
+
203
+ # ---------------------------------------------------------------- validation
204
+
205
+ def _no_control_chars(value: object, where: str) -> None:
206
+ if isinstance(value, str):
207
+ check(not CONTROL_RE.search(value), f"{where}: control characters not allowed")
208
+ elif isinstance(value, dict):
209
+ for k, v in value.items():
210
+ _no_control_chars(k, where)
211
+ _no_control_chars(v, f"{where}.{k}")
212
+ elif isinstance(value, list):
213
+ for v in value:
214
+ _no_control_chars(v, where)
215
+
216
+
217
+ def _build(cls, table: object, where: str, nested: Tuple[str, ...] = ()):
218
+ """A frozen `cls` from a TOML table: unknown keys and missing required keys are errors.
219
+ Keys in `nested` are already-built objects (trailers) and skip the control-char walk."""
220
+ check(isinstance(table, dict), f"{where}: expected a table")
221
+ _no_control_chars({k: v for k, v in table.items() if k not in nested}, where)
222
+ known = [f.name for f in fields(cls)]
223
+ unknown = sorted(set(table) - set(known))
224
+ check(not unknown, f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {', '.join(known)})")
225
+ missing = [f.name for f in fields(cls)
226
+ if f.default is MISSING and f.default_factory is MISSING and f.name not in table]
227
+ check(not missing, f"{where}: missing {', '.join(missing)}")
228
+ return cls(**table)
229
+
230
+
231
+ def _build_nested(cls, table: object, where: str):
232
+ """A nested table such as [default.trailers]; same rules as _build."""
233
+ return _build(cls, table, where)
234
+
235
+
236
+ def _check_path(p: object, where: str) -> None:
237
+ if p is not None:
238
+ check(isinstance(p, str) and PATH_RE.match(p) and ".." not in p.split("/"),
239
+ f"{where}: must start with ~/ or /, use only [A-Za-z0-9._/@+-], no '..'")
240
+
241
+
242
+ def _check_host(h: object, where: str) -> None:
243
+ check(isinstance(h, str) and HOST_RE.match(h), f"{where}: bad host {h!r}")
244
+ check(not PLACEHOLDER_RE.search(h), f"{where}: {h} is a placeholder from the example; use your real host")
245
+
246
+
247
+ def _check_person(obj: Default | Identity, where: str, required: bool) -> None:
248
+ if not required and obj.name is None and obj.email is None:
249
+ return
250
+ check(isinstance(obj.name, str) and obj.name.strip() and not re.search(r"[<>]", obj.name),
251
+ f"{where}: bad or missing name")
252
+ check(isinstance(obj.email, str) and EMAIL_RE.match(obj.email), f"{where}: bad or missing email")
253
+ check(not PLACEHOLDER_RE.search(obj.email.rsplit("@", 1)[1]),
254
+ f"{where}: {obj.email} is a placeholder from the example; use your real email")
255
+
256
+
257
+ def _check_bool(v: object, where: str, optional: bool = False) -> None:
258
+ check(isinstance(v, bool) or (optional and v is None), f"{where}: must be true or false")
259
+
260
+
261
+ def _dupes(values) -> set:
262
+ seen, out = set(), set()
263
+ for v in values:
264
+ (out if v in seen else seen).add(v)
265
+ return out
266
+
267
+
268
+ def _check_list(v: object, where: str, what: str = "a list") -> None:
269
+ check(isinstance(v, tuple), f"{where} must be {what}")
270
+
271
+
272
+ def _check_strings(v: object, where: str) -> None:
273
+ """A list of glob strings (control characters were already refused by _build)."""
274
+ check(isinstance(v, tuple) and all(isinstance(x, str) for x in v), f"{where} must be a list of strings")
275
+
276
+
277
+ def _check_dir(p: object, where: str, key: str) -> None:
278
+ """PATH_RE paths with the 3.2 message: `<where>: <key> must start with ~/ or /`."""
279
+ if p is not None:
280
+ check(isinstance(p, str) and PATH_RE.match(p) and ".." not in p.split("/"),
281
+ f"{where}: {key} must start with ~/ or /, use only [A-Za-z0-9._/@+-], no '..'")
282
+
283
+
284
+ def _check_account(v: object, where: str, key: str) -> None:
285
+ if v is not None:
286
+ check(isinstance(v, str) and ID_RE.match(v), f"{where}: {key} must match [A-Za-z0-9][A-Za-z0-9._-]*")
287
+
288
+
289
+ def _check_enum(v: object, allowed: Tuple[str, ...], where: str, key: str) -> None:
290
+ if len(allowed) == 2:
291
+ words = f'"{allowed[0]}" or "{allowed[1]}"'
292
+ else:
293
+ words = ", ".join(f'"{a}"' for a in allowed[:-1]) + f' or "{allowed[-1]}"'
294
+ check(v in allowed, f"{where}: {key} must be {words}")
295
+
296
+
297
+ def _with_trailers(table: object, where: str) -> object:
298
+ """Copy of an identity/default table with its `trailers` sub-table built and validated;
299
+ `where` names the sub-table ("[default.trailers]", "identity 'x'.trailers")."""
300
+ if not isinstance(table, dict) or "trailers" not in table:
301
+ return table
302
+ t = _build_nested(Trailers, table["trailers"], where)
303
+ if t.co_authored_by is not None:
304
+ _check_enum(t.co_authored_by, ("allow", "forbid", "require"), where, "co_authored_by")
305
+ if t.forbid_emails is not None:
306
+ _check_strings(t.forbid_emails, f"{where}: forbid_emails")
307
+ return dict(table, trailers=t)
308
+
309
+
310
+ def _check_extras(obj: "Default | Identity", where: str) -> None:
311
+ """The keys package 01 added to [default] and [[identity]]."""
312
+ _check_account(obj.gh_account, where, "gh_account")
313
+ _check_account(obj.glab_account, where, "glab_account")
314
+ _check_dir(obj.clone_dir, where, "clone_dir")
315
+
316
+
317
+ LAYOUT_RE = re.compile(r"^(?:\{(?:host|owner|repo|id)\}|[A-Za-z0-9._/-])+$")
318
+ TOP_LEVEL = ("agents", "clone", "default", "forge", "guard", "identity", "policy", "scan")
319
+ HOOKS_DIR = ".config/gitpro/hooks" # gitpro's own global hooks dir, relative to home (06)
320
+
321
+
322
+ def _expand(p: str, home: Path) -> Path:
323
+ return home / p[2:] if p.startswith("~/") else Path(p)
324
+
325
+
326
+ def scan_root_error(r: object) -> str | None:
327
+ """Why r cannot be a [scan] roots entry, else None. Roots are only walked, never written into a
328
+ gitconfig or ssh config, so unlike PATH_RE any character but a control character is allowed
329
+ (spaces, non-ASCII, WSL's /mnt/c/Users/First Last, /)."""
330
+ if not isinstance(r, str):
331
+ return "roots must be strings"
332
+ shown = json.dumps(r, ensure_ascii=False).replace("\x7f", "\\u007f")
333
+ if SCAN_CONTROL_RE.search(r):
334
+ return f"root {shown} contains a control character"
335
+ if not (r == "~" or r.startswith(("~/", "/"))):
336
+ return f"roots must start with ~/ or / (or be ~): {shown}"
337
+ if ".." in r.split("/"):
338
+ return f"root {shown} must not contain a .. segment"
339
+ return None
340
+
341
+
342
+ def _scan(table: object) -> Scan:
343
+ w = "[scan]"
344
+ if isinstance(table, dict) and "roots" in table:
345
+ check(isinstance(table["roots"], list), f"{w}: roots must be a list")
346
+ s = _build(Scan, table, w)
347
+ _check_list(s.roots, f"{w}: roots")
348
+ for r in s.roots:
349
+ err = scan_root_error(r)
350
+ check(err is None, f"{w}: {err}")
351
+ _check_strings(s.exclude, f"{w}: exclude")
352
+ check(type(s.stale_days) is int and s.stale_days > 0, f"{w}: stale_days must be a positive integer")
353
+ return s
354
+
355
+
356
+ def _guard(table: object, home: Path) -> Guard:
357
+ w = "[guard]"
358
+ g = _build(Guard, table, w)
359
+ _check_bool(g.hooks, f"{w}.hooks")
360
+ _check_enum(g.on_mismatch, ("block", "warn"), w, "on_mismatch")
361
+ check(isinstance(g.chain_to, str), f"{w}: chain_to must start with ~/ or /")
362
+ if g.chain_to:
363
+ check(PATH_RE.match(g.chain_to) and ".." not in g.chain_to.split("/"),
364
+ f"{w}: chain_to must start with ~/ or /")
365
+ own = os.path.realpath(home / HOOKS_DIR)
366
+ check(os.path.realpath(_expand(g.chain_to, home)) != own,
367
+ f"{w}: chain_to must not be gitpro's own hooks dir (~/{HOOKS_DIR})")
368
+ return g
369
+
370
+
371
+ def _clone(table: object) -> Clone:
372
+ w = "[clone]"
373
+ c = _build(Clone, table, w)
374
+ _check_dir(c.dir, w, "dir")
375
+ lay = c.layout
376
+ check(isinstance(lay, str) and LAYOUT_RE.match(lay) and not lay.startswith("/")
377
+ and ".." not in lay.split("/") and "" not in lay.split("/"),
378
+ f"{w}: layout may use only {{host}} {{owner}} {{repo}} {{id}} and [A-Za-z0-9._/-]")
379
+ return c
380
+
381
+
382
+ def _forge(table: object) -> Forge:
383
+ f = _build(Forge, table, "[forge]")
384
+ _check_bool(f.online, "[forge].online")
385
+ _check_bool(f.similar, "[forge].similar")
386
+ return f
387
+
388
+
389
+ def _agents(table: object) -> Agents:
390
+ a = _build(Agents, table, "[agents]")
391
+ _check_enum(a.hook, ("block", "warn", "off"), "[agents]", "hook")
392
+ _check_strings(a.bot_emails, "[agents]: bot_emails")
393
+ return a
394
+
395
+
396
+ def _policy(table: object) -> Policy:
397
+ p = _build(Policy, table, "[policy]")
398
+ _check_list(p.files, "[policy]: files")
399
+ for f in p.files:
400
+ _check_dir(f, "[policy]", "files")
401
+ return p
402
+
403
+
404
+ def _read(path: Path) -> dict:
405
+ """Permission check + TOML parse; the shared first step of load_all and load_scan."""
406
+ try:
407
+ st = path.stat()
408
+ raw = path.read_text()
409
+ except FileNotFoundError:
410
+ raise ConfigError(f"{path}: not found") from None
411
+ # The config decides what commands git runs (sshCommand, credential helpers).
412
+ check(st.st_uid == os.getuid() and not st.st_mode & 0o022,
413
+ f"{path}: must be owned by you and not group/world-writable (chmod 600 {path})")
414
+ return _load_toml(raw, path)
415
+
416
+
417
+ def _load_toml(text: str, path: object = "config") -> dict:
418
+ try:
419
+ return _toml.loads(text)
420
+ except _toml.TOMLDecodeError as e:
421
+ raise ConfigError(f"{path}: {e}") from None
422
+
423
+
424
+ def load_all(path: Path, how: str = "") -> Config:
425
+ """Load and validate every table; the Config other packages read."""
426
+ return _parse_all(_read(path), path, how)
427
+
428
+
429
+ def loads(text: str, path: Path, how: str = "") -> Config:
430
+ """Validate proposed configuration without writing it to disk."""
431
+ return _parse_all(_load_toml(text, path), path, how)
432
+
433
+
434
+ def _parse_all(data: dict, path: Path, how: str) -> Config:
435
+ home = Path.home()
436
+
437
+ unknown = sorted(set(data) - set(TOP_LEVEL))
438
+ check(not unknown, f"{path}: unknown key(s) {', '.join(unknown)} (allowed: {', '.join(TOP_LEVEL)})")
439
+ check("default" in data, f"{path}: missing [default]")
440
+ check(isinstance(data.get("identity", []), list), f"{path}: write identities as [[identity]]")
441
+
442
+ d = _build(Default, _with_trailers(data["default"], "[default.trailers]"), "default",
443
+ nested=("trailers",))
444
+ _check_person(d, "default", required=False)
445
+ _check_path(d.ssh_key, "default.ssh_key")
446
+ _check_bool(d.sign, "default.sign")
447
+ check(isinstance(d.git, dict), "default.git: must be a table")
448
+ check(isinstance(d.ssh_hosts, tuple), "default.ssh_hosts: must be a list")
449
+ check(not d.ssh_hosts or d.ssh_key, "default: ssh_hosts needs ssh_key")
450
+ check(not d.sign or (d.ssh_key and d.email), "default: sign needs ssh_key and email")
451
+ for h in d.ssh_hosts:
452
+ _check_host(h, "default.ssh_hosts")
453
+ _check_extras(d, "[default]")
454
+
455
+ ids = []
456
+ for n, tbl in enumerate(data.get("identity", [])):
457
+ tid = tbl.get("id") if isinstance(tbl, dict) else None
458
+ tw = f"identity {tid!r}" if isinstance(tid, str) and ID_RE.match(tid) else f"identity[{n}]"
459
+ i = _build(Identity, _with_trailers(tbl, f"{tw}.trailers"), f"identity[{n}]", nested=("trailers",))
460
+ check(isinstance(i.id, str) and ID_RE.match(i.id), f"identity[{n}]: bad id {i.id!r}")
461
+ w = f"identity {i.id!r}"
462
+ _check_person(i, w, required=True)
463
+ _check_host(i.host, w)
464
+ check(isinstance(i.owners, tuple), f"{w}: owners must be a list")
465
+ for o in i.owners:
466
+ check(isinstance(o, str) and OWNER_RE.match(o), f"{w}: bad owner {o!r}")
467
+ check(isinstance(i.ssh_user, str) and USER_RE.match(i.ssh_user), f"{w}: bad ssh_user")
468
+ check(i.ssh_port is None or (type(i.ssh_port) is int and 0 < i.ssh_port < 65536),
469
+ f"{w}: bad ssh_port")
470
+ _check_path(i.ssh_key, f"{w}.ssh_key")
471
+ _check_path(i.file, f"{w}.file")
472
+ _check_bool(i.rewrite, f"{w}.rewrite")
473
+ _check_bool(i.rewrite_https, f"{w}.rewrite_https")
474
+ _check_bool(i.sign, f"{w}.sign", optional=True)
475
+ check(isinstance(i.git, dict), f"{w}.git: must be a table")
476
+ check(not signs(i, d) or i.ssh_key or d.ssh_key, f"{w}: signing needs ssh_key here or in [default]")
477
+ if i.ssh_alias is not None:
478
+ check(isinstance(i.ssh_alias, str) and HOST_RE.match(i.ssh_alias) and i.ssh_alias != i.host,
479
+ f"{w}: bad ssh_alias")
480
+ check(i.ssh_key, f"{w}: ssh_alias needs ssh_key")
481
+ _check_extras(i, w)
482
+ _check_dir(i.policy, w, "policy")
483
+ ids.append(replace(i, file=i.file or f"~/.gitconfig-{i.id}"))
484
+
485
+ check(not _dupes(i.id for i in ids), f"duplicate ids: {_dupes(i.id for i in ids)}")
486
+ check(not _dupes(i.file for i in ids), f"duplicate files: {_dupes(i.file for i in ids)}")
487
+ ssh_names = list(d.ssh_hosts) + [i.ssh_alias for i in ids if i.ssh_alias]
488
+ check(not _dupes(ssh_names), f"duplicate ssh Host names: {_dupes(ssh_names)}")
489
+
490
+ return Config(
491
+ default=d, identities=tuple(ids),
492
+ scan=_scan(data.get("scan", {})),
493
+ guard=_guard(data.get("guard", {}), home),
494
+ clone=_clone(data.get("clone", {})),
495
+ forge=_forge(data.get("forge", {})),
496
+ agents=_agents(data.get("agents", {})),
497
+ policy=_policy(data.get("policy", {})),
498
+ path=path, how=how,
499
+ )
500
+
501
+
502
+ def load(path: Path) -> "tuple[Default, list[Identity]]":
503
+ """The pre-0.2 shape, kept for existing callers: (Default, list of identities)."""
504
+ c = load_all(path)
505
+ return c.default, list(c.identities)
506
+
507
+
508
+ def load_scan(path: Path) -> Scan:
509
+ """Only [scan], for scan (02) and lost (03), which must work on a config `init` just wrote with
510
+ placeholders: every other table is ignored, so placeholders or errors elsewhere never matter.
511
+
512
+ It raises ConfigError (it does not return Scan() itself, unlike the first draft of spec 01 §3.3)
513
+ when the file is missing, unreadable, not valid TOML, or its [scan] table is bad. The caller
514
+ decides what that means: scan/lost catch it, print `⚠ <path>: <error>; scanning ~` once and use
515
+ Scan(); a caller that needs the configured roots can report the error instead."""
516
+ data = _read(path)
517
+ return _scan(data.get("scan", {}))