pushkin-core 0.2.1

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
//! `pushkin.toml` parsing (spec §11). Strict by construction: serde with
//! `deny_unknown_fields` everywhere, unknown-key errors enriched with
//! nearest-candidate suggestions, mapping→contract references resolved once
//! at the boundary (spec §7.1) so shorthand never silently changes meaning.
//!
//! One proportionality exception, mechanical rather than judged (F79,
//! ADR-0007): a field wrapped in [`DisplayOnly`] degrades an unrecognized
//! string to its default and carries the raw value for disclosure, because a
//! typo in a field that reaches no verdict should cost a NOTE line, not every
//! verb. Every other key and value keeps rejecting.

use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::de::value::StrDeserializer;
use serde::de::{DeserializeOwned, Deserializer};
use serde::Deserialize;
use thiserror::Error;

pub const SUPPORTED_VERSION: u32 = 1;

/// Keys a typo in the manifest is matched against for candidate suggestions.
const KNOWN_KEYS: &[&str] = &[
    "version",
    "schema_epoch",
    "canonical",
    "authoring",
    "contracts",
    "name",
    "source",
    "emit",
    "mappings",
    "glob",
    "require",
    "gates",
    "suppression_comments",
    "protected_paths",
    "read_only_paths",
    "retrieval_paths",
    "retrieval_tool",
    "db",
    "direction",
    "provider",
    "rls_tests",
    "features",
    "git_hooks",
    "floor",
    "commands",
    "run",
    "scope",
    "inputs",
    "install",
    "on_stop",
    "on_new_read_only",
    "reconcile_ignored",
    "covers_ignored_of",
];

#[derive(Debug, Error)]
pub enum ManifestError {
    #[error("manifest is not valid TOML or violates the schema: {message}")]
    Invalid { message: String },
    #[error("manifest version {found} is unsupported (this binary supports {supported})")]
    UnsupportedVersion { found: u32, supported: u32 },
    #[error(
        "mapping references undeclared contract '{reference}'; declared contracts: {candidates}"
    )]
    UnknownContract {
        reference: String,
        candidates: String,
    },
    #[error("glob '{glob}' is invalid: {message}")]
    BadGlob { glob: String, message: String },
    #[error(
        "schema_epoch must be a positive integer (a human increments it on \
         epoch-sensitive change, R9); found {found}"
    )]
    NonPositiveEpoch { found: u32 },
    #[error(
        "[[floor.commands]] declares duplicate name '{name}'; every floor \
         command needs a unique name (--skip and covers_ignored_of both \
         address commands by name)"
    )]
    DuplicateFloorCommand { name: String },
    #[error(
        "floor command '{name}' declares an empty `run` array; a command with \
         nothing to run cannot produce a verdict (remove the entry, or give it \
         an argv: run = [\"cargo\", \"fmt\", \"--check\"])"
    )]
    EmptyFloorRun { name: String },
    #[error(
        "floor command '{name}' declares covers_ignored_of = '{reference}', \
         which is not a declared command; declared commands: {candidates}"
    )]
    UnknownFloorCoverage {
        name: String,
        reference: String,
        candidates: String,
    },
    #[error(
        "floor command '{name}' declares covers_ignored_of = '{name}' — a \
         command cannot cover its own ignored tests; the accounting would \
         balance while executing nothing new"
    )]
    SelfFloorCoverage { name: String },
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct ContractName(String);

impl ContractName {
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Contract {
    pub name: ContractName,
    pub source: String,
    pub emit: Vec<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mapping {
    pub glob: String,
    pub contracts: Vec<ContractName>,
    pub require: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Gates {
    pub suppression_comments: Option<String>,
    #[serde(default)]
    pub protected_paths: Vec<String>,
    /// Globs whose COMMITTED files are read-only to agents: new files may
    /// be created (the RED-suite authoring window), files in git HEAD may
    /// not be modified — N10 ("committed first, read-only hereafter") as a
    /// product gate. Unwaivable, like `protected_paths`.
    #[serde(default)]
    pub read_only_paths: Vec<String>,
    /// SPIKE — the read contract. Globs whose files an agent must reach
    /// through `retrieval_tool` rather than an unbounded whole-file read.
    ///
    /// A read carrying an explicit range is allowed **on the `Read` surface**,
    /// where the host supplies `offset`/`limit` as structured fields the gate
    /// can verify: that is the deliberate shape, and the host's
    /// read-before-edit gate needs it. A shell reader gets no such allowance,
    /// because a bound spelled inside a command string can only be inferred
    /// and `head -999999` is indistinguishable from `head -50`. The asymmetry
    /// is the verifiability of the bound, not an inconsistency (hook-matcher-gap
    /// charter, Addendum D, HM-10).
    #[serde(default)]
    pub retrieval_paths: Vec<String>,
    /// The tool a denied read is redirected to. A manifest string, never a
    /// hard-coded vendor, so a future in-tree Pushkin index can take the
    /// slot without changing the gate.
    pub retrieval_tool: Option<String>,
}

/// `[db]` (spec §5.3, §10): drift-gate configuration. `direction` names
/// the source of truth — "contract" (generated DDL is desired state) or
/// "database" (introspected schema is; contracts must follow).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Db {
    pub direction: DbDirection,
    pub provider: Option<String>,
    pub rls_tests: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DbDirection {
    Contract,
    Database,
}

/// `[features]` — repo-level switches for whole enforcement planes,
/// committed in the manifest so every surface (init, doctor, the
/// pre-commit floor, CI) reads one truth. The manifest is a protected
/// path, so the switch is human-owned by construction. Absent table =
/// every feature enabled: only a positively parsed `false` turns a
/// plane off, mirroring the N13 principle (act on positive probes,
/// never on ambiguity).
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Features {
    /// The git-plane floor as one switch: the lefthook pre-commit
    /// block, the native `.git/hooks` shim, and the staged check they
    /// both run. `false` = init refuses to install either surface,
    /// doctor stops checking them, and `check --staged` passes with a
    /// stderr notice. Deliberately NOT covered: agent-side write gating
    /// (`hook`, stdin `check`) — the flag turns off commit protection,
    /// never write-time contract enforcement.
    #[serde(default = "default_enabled")]
    pub git_hooks: bool,
}

impl Default for Features {
    fn default() -> Self {
        Self {
            git_hooks: default_enabled(),
        }
    }
}

fn default_enabled() -> bool {
    true
}

/// `[floor]` (spec §8.2 stage 5) — the declared mechanical floor: the one
/// committed list of commands `pushkin floor`, the `Makefile`, `scripts/floor.sh`
/// and CI all read, so "mirrors CI commands exactly" is a fact rather than a
/// promise someone has to remember.
///
/// Optional. A repo without a declared floor is a valid manifest; `pushkin
/// floor` is the surface that refuses to run against one, because pre-empting
/// that here would break every other verb on a manifest that was never wrong.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Floor {
    /// Run in declared order. Order is load-bearing: it is the order the verb
    /// executes and reports in.
    #[serde(default)]
    pub commands: Vec<FloorCommand>,
}

/// How far a command's verdict reaches — **declared, never inferred.**
///
/// Nothing in this pass executes differently per scope; everything runs
/// whole-repo. The field exists because the alternative is a tool guessing at
/// decomposability, and a wrong guess narrows the check silently. It is the
/// contract a future warm charter reads, recorded honestly now while the facts
/// are in front of us: only `cargo fmt --check` is per-file faithful (and only
/// for a NAMED file — `cargo fmt` discovery skips cfg-gated out-of-line modules,
/// rustfmt #4034), clippy is a whole-crate rustc driver, and cargo test targets
/// are crate-level binaries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FloorScope {
    PerFile,
    PerCrate,
    WholeRepo,
}

/// What a command's verdict depends on BEYOND the repo contents.
///
/// `network` is the honest one: `cargo deny`'s `advisories` check consults the
/// `RustSec` DB, so the floor is not a pure function of the commit — an unchanged
/// commit can newly fail when an advisory publishes. The verb discloses that in
/// its output rather than letting it ambush the next unrelated PR (F69 rider).
///
/// `machine` is the second one, and it has a different cause: a command that
/// measures wall-clock time answers about the machine as much as about the
/// commit. `bench` asserts a latency threshold, so a busy machine fails a commit
/// that passes quiet — observed as a full floor going RED at 836/1 under
/// concurrent cargo builds and green at 837/0 on the same tree idle (F76
/// addendum). Disclosed separately from `network` because the reasons differ and
/// a reader who cannot tell them apart learns to skip both.
///
/// `repo` is the `Default` because it is what an unrecognized value degrades
/// to (F79, ADR-0007): the most conservative reading, claiming no dependency
/// the command did not declare, so a typo never adds a command to the network
/// or machine disclosure and never removes one spelled correctly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FloorInputs {
    #[default]
    Repo,
    Toolchain,
    Network,
    Machine,
}

/// The marker for a manifest field whose value PROVABLY reaches no verdict
/// (F79, ADR-0007 Option A).
///
/// Strictness in this file is proportional to consequence. An unknown KEY may
/// be a gate rule being silently ignored, so it rejects (ADR-0001). An unknown
/// value in a field that only feeds disclosure text costs one NOTE line, and
/// charging it the whole-manifest price took every verb down together — in the
/// fail-open direction (F71). So: a field wrapped in this type degrades an
/// unrecognized **string** to `T::default()` and keeps the raw text, so the
/// value carries its own provenance to wherever it is rendered. A value of the
/// wrong TOML type is a malformed manifest, not a typo, and still rejects.
///
/// The leniency is a property of the type, not of a reviewer's per-field call:
/// `crates/pushkin-core/tests/manifest_display_only.rs` scans this file and
/// asserts the marker sits on exactly one field. Wrapping another field changes
/// that list, which is what makes it a reviewed act rather than a silent
/// widening — Option C of the record (every unknown value warns) is the
/// N13-forbidden shape, and this guard is what keeps A from drifting into it.
///
/// Compares equal to the value it carries, so call sites and committed suites
/// that compare against the plain enum keep reading naturally. A degradation
/// is never silent: the consumer that renders the field owns the disclosure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisplayOnly<T> {
    value: T,
    degraded_from: Option<String>,
}

impl<T> DisplayOnly<T> {
    #[must_use]
    pub fn value(&self) -> &T {
        &self.value
    }

    /// The raw text the parser did not recognize, when the value degraded.
    #[must_use]
    pub fn degraded_from(&self) -> Option<&str> {
        self.degraded_from.as_deref()
    }
}

impl<T: PartialEq> PartialEq<T> for DisplayOnly<T> {
    fn eq(&self, other: &T) -> bool {
        self.value == *other
    }
}

impl<'de, T: DeserializeOwned + Default> Deserialize<'de> for DisplayOnly<T> {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let raw = String::deserialize(deserializer)?;
        let recognized = T::deserialize(StrDeserializer::<D::Error>::new(&raw));
        Ok(match recognized {
            Ok(value) => Self {
                value,
                degraded_from: None,
            },
            Err(_) => Self {
                value: T::default(),
                degraded_from: Some(raw),
            },
        })
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FloorCommand {
    /// Unique across the table: `--skip` and `covers_ignored_of` both address
    /// commands by name.
    pub name: String,
    /// argv, never a shell string — a shell string is an injection surface and
    /// a quoting-bug surface, and neither belongs in a gate.
    pub run: Vec<String>,
    pub scope: FloorScope,
    /// Display-only (F79): its three readers are all disclosure text in the
    /// floor verb, so an unrecognized value degrades to `repo` with a NOTE
    /// instead of rejecting the manifest.
    pub inputs: DisplayOnly<FloorInputs>,
    /// The install hint a missing binary's error carries (the `db.rs`
    /// `run_tool` pattern: an absent tool is a loud named failure, never a
    /// silent skip).
    pub install: Option<String>,
    /// Whether the Stop sweep runs this command. Default `false` — the
    /// conservative posture, and the whole Stop integration ships dark until a
    /// human rules a command in.
    #[serde(default)]
    pub on_stop: bool,
    /// Whether this command runs as a pre-RED lint gate: when a commit stages a
    /// NEW file under a `read_only_paths` glob, `check --staged` runs this
    /// command before the file is frozen (F72). Default `false`, mirroring
    /// `on_stop` — the mechanism ships dark until a human opts a command in.
    #[serde(default)]
    pub on_new_read_only: bool,
    /// Whether this command's output carries cargo-test-shaped `test result:`
    /// lines whose ignored count must be accounted for.
    #[serde(default)]
    pub reconcile_ignored: bool,
    /// Declares that THIS command executes the tests the named command reported
    /// as ignored. The link is declared rather than guessed because the
    /// accounting is only as trustworthy as the claim it checks.
    pub covers_ignored_of: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawManifest {
    version: u32,
    schema_epoch: Option<u32>,
    canonical: String,
    authoring: String,
    #[serde(default)]
    contracts: Vec<Contract>,
    #[serde(default)]
    mappings: Vec<Mapping>,
    gates: Gates,
    db: Option<Db>,
    #[serde(default)]
    features: Features,
    floor: Option<Floor>,
}

/// A parsed, boundary-resolved manifest. Globs are compiled once here.
pub struct Manifest {
    pub version: u32,
    /// R9 (approved 2026-08-13): the workspace-wide schema epoch, owned by
    /// the manifest and human-incremented. The SOLE source authoring,
    /// compile, and the daemon probe read. Absent key = 1 (pre-R9
    /// manifests keep parsing; the repo's own manifest declares it).
    pub schema_epoch: u32,
    pub canonical: String,
    pub authoring: String,
    pub contracts: Vec<Contract>,
    pub mappings: Vec<Mapping>,
    pub gates: Gates,
    pub db: Option<Db>,
    pub features: Features,
    /// `[floor]` — absent when the repo declares no mechanical floor. The verb
    /// owns that refusal, not the parser.
    pub floor: Option<Floor>,
    mapping_globs: GlobSet,
    protected_globs: GlobSet,
    read_only_globs: GlobSet,
    retrieval_globs: GlobSet,
}

impl std::fmt::Debug for Manifest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // GlobSet has no Debug; show the declarative fields only.
        f.debug_struct("Manifest")
            .field("version", &self.version)
            .field("schema_epoch", &self.schema_epoch)
            .field("canonical", &self.canonical)
            .field("authoring", &self.authoring)
            .field("contracts", &self.contracts)
            .field("mappings", &self.mappings)
            .field("gates", &self.gates)
            .field("db", &self.db)
            .field("features", &self.features)
            .field("floor", &self.floor)
            .finish_non_exhaustive()
    }
}

impl Manifest {
    /// Parses and boundary-resolves manifest text.
    ///
    /// # Errors
    /// Returns `ManifestError` on TOML/schema violations (with candidate
    /// suggestions for unknown keys), unsupported versions, undeclared
    /// contract references, and invalid globs.
    pub fn parse(text: &str) -> Result<Self, ManifestError> {
        let raw: RawManifest = toml::from_str(text).map_err(|e| enrich_unknown_key(&e))?;

        if raw.version != SUPPORTED_VERSION {
            return Err(ManifestError::UnsupportedVersion {
                found: raw.version,
                supported: SUPPORTED_VERSION,
            });
        }
        if let Some(0) = raw.schema_epoch {
            return Err(ManifestError::NonPositiveEpoch { found: 0 });
        }
        resolve_contract_references(&raw)?;
        if let Some(floor) = raw.floor.as_ref() {
            validate_floor(floor)?;
        }

        let mapping_globs = build_globset(raw.mappings.iter().map(|m| m.glob.as_str()))?;
        let protected_globs = build_globset(raw.gates.protected_paths.iter().map(String::as_str))?;
        let read_only_globs = build_globset(raw.gates.read_only_paths.iter().map(String::as_str))?;
        let retrieval_globs = build_globset(raw.gates.retrieval_paths.iter().map(String::as_str))?;

        Ok(Self {
            version: raw.version,
            schema_epoch: raw.schema_epoch.unwrap_or(1),
            canonical: raw.canonical,
            authoring: raw.authoring,
            contracts: raw.contracts,
            mappings: raw.mappings,
            gates: raw.gates,
            db: raw.db,
            features: raw.features,
            floor: raw.floor,
            mapping_globs,
            protected_globs,
            read_only_globs,
            retrieval_globs,
        })
    }

    /// First mapping whose glob matches `path`, if any.
    #[must_use]
    pub fn mapping_for(&self, path: &str) -> Option<&Mapping> {
        self.mapping_globs
            .matches(path)
            .first()
            .map(|&index| &self.mappings[index])
    }

    #[must_use]
    pub fn is_protected(&self, path: &str) -> bool {
        self.protected_globs.is_match(path)
    }

    /// Whether `path` falls under a `read_only_paths` glob. Committed-ness
    /// is the caller's question (it needs git); this is only the glob half.
    #[must_use]
    pub fn is_read_only(&self, path: &str) -> bool {
        self.read_only_globs.is_match(path)
    }

    /// Whether `path` falls under a `retrieval_paths` glob. Whether the
    /// READ was bounded is the caller's question; this is only the glob
    /// half, mirroring `is_read_only`.
    #[must_use]
    pub fn is_retrieval_gated(&self, path: &str) -> bool {
        self.retrieval_globs.is_match(path)
    }

    /// The declared retrieval destination, if the manifest names one.
    #[must_use]
    pub fn retrieval_tool(&self) -> Option<&str> {
        self.gates.retrieval_tool.as_deref()
    }

    /// The `[features]` git-plane switch. `true` unless the manifest
    /// positively declares `git_hooks = false`.
    #[must_use]
    pub fn git_hooks_enabled(&self) -> bool {
        self.features.git_hooks
    }
}

fn resolve_contract_references(raw: &RawManifest) -> Result<(), ManifestError> {
    let declared: Vec<&str> = raw.contracts.iter().map(|c| c.name.as_str()).collect();
    for mapping in &raw.mappings {
        for reference in &mapping.contracts {
            if !declared.contains(&reference.as_str()) {
                return Err(ManifestError::UnknownContract {
                    reference: reference.as_str().to_owned(),
                    candidates: declared.join(", "),
                });
            }
        }
    }
    Ok(())
}

/// The `[floor]` invariants serde cannot express: names unique, every `run`
/// non-empty, and every `covers_ignored_of` resolving to some OTHER declared
/// command.
///
/// The coverage rules exist because the ignored-test accounting is only as
/// trustworthy as the claim it checks. A dangling reference makes the accounting
/// vacuous; self-coverage balances its arithmetic while executing nothing new.
/// Both are the shape of defect `scripts/floor.sh` was written to prevent — a
/// floor citation that counts less than it claims (D7(a), F62).
fn validate_floor(floor: &Floor) -> Result<(), ManifestError> {
    let mut seen: Vec<&str> = Vec::with_capacity(floor.commands.len());
    for command in &floor.commands {
        if seen.contains(&command.name.as_str()) {
            return Err(ManifestError::DuplicateFloorCommand {
                name: command.name.clone(),
            });
        }
        seen.push(&command.name);
        if command.run.is_empty() {
            return Err(ManifestError::EmptyFloorRun {
                name: command.name.clone(),
            });
        }
    }
    // Resolved by name across the WHOLE table, so a coverer may be declared
    // before the command it covers; a forward-only scan would make the link
    // order-dependent and the error message a lie.
    for command in &floor.commands {
        let Some(reference) = command.covers_ignored_of.as_deref() else {
            continue;
        };
        if reference == command.name {
            return Err(ManifestError::SelfFloorCoverage {
                name: command.name.clone(),
            });
        }
        if !seen.contains(&reference) {
            return Err(ManifestError::UnknownFloorCoverage {
                name: command.name.clone(),
                reference: reference.to_owned(),
                candidates: seen.join(", "),
            });
        }
    }
    Ok(())
}

fn build_globset<'a>(globs: impl Iterator<Item = &'a str>) -> Result<GlobSet, ManifestError> {
    let mut builder = GlobSetBuilder::new();
    for glob in globs {
        let compiled = Glob::new(glob).map_err(|error| ManifestError::BadGlob {
            glob: glob.to_owned(),
            message: error.to_string(),
        })?;
        builder.add(compiled);
    }
    builder.build().map_err(|error| ManifestError::BadGlob {
        glob: "<combined>".to_owned(),
        message: error.to_string(),
    })
}

/// Appends nearest-candidate suggestions to serde's "unknown field" errors so
/// every rejection is a retry prompt (design principle 5).
fn enrich_unknown_key(error: &toml::de::Error) -> ManifestError {
    let message = error.to_string();
    let Some(unknown) = extract_unknown_field(&message) else {
        return ManifestError::Invalid { message };
    };
    let candidates = nearest_keys(&unknown);
    if candidates.is_empty() {
        return ManifestError::Invalid { message };
    }
    ManifestError::Invalid {
        message: format!("{message}; did you mean: {}?", candidates.join(", ")),
    }
}

fn extract_unknown_field(message: &str) -> Option<String> {
    let marker = "unknown field `";
    let start = message.find(marker)? + marker.len();
    let rest = &message[start..];
    let end = rest.find('`')?;
    Some(rest[..end].to_owned())
}

fn nearest_keys(unknown: &str) -> Vec<&'static str> {
    let mut scored: Vec<(usize, &'static str)> = KNOWN_KEYS
        .iter()
        .map(|&key| (levenshtein(unknown, key), key))
        .filter(|&(distance, _)| distance <= 3)
        .collect();
    scored.sort_unstable();
    scored.into_iter().take(3).map(|(_, key)| key).collect()
}

pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
    let a_chars: Vec<char> = a.chars().collect();
    let b_chars: Vec<char> = b.chars().collect();
    let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
    let mut current = vec![0usize; b_chars.len() + 1];

    for (i, &a_char) in a_chars.iter().enumerate() {
        current[0] = i + 1;
        for (j, &b_char) in b_chars.iter().enumerate() {
            let substitution = usize::from(a_char != b_char);
            current[j + 1] = (previous[j] + substitution)
                .min(previous[j + 1] + 1)
                .min(current[j] + 1);
        }
        std::mem::swap(&mut previous, &mut current);
    }
    previous[b_chars.len()]
}