Skip to main content

pushkin_core/
manifest.rs

1//! `pushkin.toml` parsing (spec §11). Strict by construction: serde with
2//! `deny_unknown_fields` everywhere, unknown-key errors enriched with
3//! nearest-candidate suggestions, mapping→contract references resolved once
4//! at the boundary (spec §7.1) so shorthand never silently changes meaning.
5
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use serde::Deserialize;
8use thiserror::Error;
9
10pub const SUPPORTED_VERSION: u32 = 1;
11
12/// Keys a typo in the manifest is matched against for candidate suggestions.
13const KNOWN_KEYS: &[&str] = &[
14    "version",
15    "schema_epoch",
16    "canonical",
17    "authoring",
18    "contracts",
19    "name",
20    "source",
21    "emit",
22    "mappings",
23    "glob",
24    "require",
25    "gates",
26    "suppression_comments",
27    "protected_paths",
28    "read_only_paths",
29    "retrieval_paths",
30    "retrieval_tool",
31    "db",
32    "direction",
33    "provider",
34    "rls_tests",
35    "features",
36    "git_hooks",
37    "floor",
38    "commands",
39    "run",
40    "scope",
41    "inputs",
42    "install",
43    "on_stop",
44    "reconcile_ignored",
45    "covers_ignored_of",
46];
47
48#[derive(Debug, Error)]
49pub enum ManifestError {
50    #[error("manifest is not valid TOML or violates the schema: {message}")]
51    Invalid { message: String },
52    #[error("manifest version {found} is unsupported (this binary supports {supported})")]
53    UnsupportedVersion { found: u32, supported: u32 },
54    #[error(
55        "mapping references undeclared contract '{reference}'; declared contracts: {candidates}"
56    )]
57    UnknownContract {
58        reference: String,
59        candidates: String,
60    },
61    #[error("glob '{glob}' is invalid: {message}")]
62    BadGlob { glob: String, message: String },
63    #[error(
64        "schema_epoch must be a positive integer (a human increments it on \
65         epoch-sensitive change, R9); found {found}"
66    )]
67    NonPositiveEpoch { found: u32 },
68    #[error(
69        "[[floor.commands]] declares duplicate name '{name}'; every floor \
70         command needs a unique name (--skip and covers_ignored_of both \
71         address commands by name)"
72    )]
73    DuplicateFloorCommand { name: String },
74    #[error(
75        "floor command '{name}' declares an empty `run` array; a command with \
76         nothing to run cannot produce a verdict (remove the entry, or give it \
77         an argv: run = [\"cargo\", \"fmt\", \"--check\"])"
78    )]
79    EmptyFloorRun { name: String },
80    #[error(
81        "floor command '{name}' declares covers_ignored_of = '{reference}', \
82         which is not a declared command; declared commands: {candidates}"
83    )]
84    UnknownFloorCoverage {
85        name: String,
86        reference: String,
87        candidates: String,
88    },
89    #[error(
90        "floor command '{name}' declares covers_ignored_of = '{name}' — a \
91         command cannot cover its own ignored tests; the accounting would \
92         balance while executing nothing new"
93    )]
94    SelfFloorCoverage { name: String },
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98#[serde(transparent)]
99pub struct ContractName(String);
100
101impl ContractName {
102    #[must_use]
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106}
107
108#[derive(Debug, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct Contract {
111    pub name: ContractName,
112    pub source: String,
113    pub emit: Vec<String>,
114}
115
116#[derive(Debug, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct Mapping {
119    pub glob: String,
120    pub contracts: Vec<ContractName>,
121    pub require: Option<String>,
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct Gates {
127    pub suppression_comments: Option<String>,
128    #[serde(default)]
129    pub protected_paths: Vec<String>,
130    /// Globs whose COMMITTED files are read-only to agents: new files may
131    /// be created (the RED-suite authoring window), files in git HEAD may
132    /// not be modified — N10 ("committed first, read-only hereafter") as a
133    /// product gate. Unwaivable, like `protected_paths`.
134    #[serde(default)]
135    pub read_only_paths: Vec<String>,
136    /// SPIKE — the read contract. Globs whose files an agent must reach
137    /// through `retrieval_tool` rather than an unbounded whole-file read.
138    ///
139    /// A read carrying an explicit range is allowed **on the `Read` surface**,
140    /// where the host supplies `offset`/`limit` as structured fields the gate
141    /// can verify: that is the deliberate shape, and the host's
142    /// read-before-edit gate needs it. A shell reader gets no such allowance,
143    /// because a bound spelled inside a command string can only be inferred
144    /// and `head -999999` is indistinguishable from `head -50`. The asymmetry
145    /// is the verifiability of the bound, not an inconsistency (hook-matcher-gap
146    /// charter, Addendum D, HM-10).
147    #[serde(default)]
148    pub retrieval_paths: Vec<String>,
149    /// The tool a denied read is redirected to. A manifest string, never a
150    /// hard-coded vendor, so a future in-tree Pushkin index can take the
151    /// slot without changing the gate.
152    pub retrieval_tool: Option<String>,
153}
154
155/// `[db]` (spec §5.3, §10): drift-gate configuration. `direction` names
156/// the source of truth — "contract" (generated DDL is desired state) or
157/// "database" (introspected schema is; contracts must follow).
158#[derive(Debug, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct Db {
161    pub direction: DbDirection,
162    pub provider: Option<String>,
163    pub rls_tests: Option<String>,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
167#[serde(rename_all = "lowercase")]
168pub enum DbDirection {
169    Contract,
170    Database,
171}
172
173/// `[features]` — repo-level switches for whole enforcement planes,
174/// committed in the manifest so every surface (init, doctor, the
175/// pre-commit floor, CI) reads one truth. The manifest is a protected
176/// path, so the switch is human-owned by construction. Absent table =
177/// every feature enabled: only a positively parsed `false` turns a
178/// plane off, mirroring the N13 principle (act on positive probes,
179/// never on ambiguity).
180#[derive(Debug, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct Features {
183    /// The git-plane floor as one switch: the lefthook pre-commit
184    /// block, the native `.git/hooks` shim, and the staged check they
185    /// both run. `false` = init refuses to install either surface,
186    /// doctor stops checking them, and `check --staged` passes with a
187    /// stderr notice. Deliberately NOT covered: agent-side write gating
188    /// (`hook`, stdin `check`) — the flag turns off commit protection,
189    /// never write-time contract enforcement.
190    #[serde(default = "default_enabled")]
191    pub git_hooks: bool,
192}
193
194impl Default for Features {
195    fn default() -> Self {
196        Self {
197            git_hooks: default_enabled(),
198        }
199    }
200}
201
202fn default_enabled() -> bool {
203    true
204}
205
206/// `[floor]` (spec §8.2 stage 5) — the declared mechanical floor: the one
207/// committed list of commands `pushkin floor`, the `Makefile`, `scripts/floor.sh`
208/// and CI all read, so "mirrors CI commands exactly" is a fact rather than a
209/// promise someone has to remember.
210///
211/// Optional. A repo without a declared floor is a valid manifest; `pushkin
212/// floor` is the surface that refuses to run against one, because pre-empting
213/// that here would break every other verb on a manifest that was never wrong.
214#[derive(Debug, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct Floor {
217    /// Run in declared order. Order is load-bearing: it is the order the verb
218    /// executes and reports in.
219    #[serde(default)]
220    pub commands: Vec<FloorCommand>,
221}
222
223/// How far a command's verdict reaches — **declared, never inferred.**
224///
225/// Nothing in this pass executes differently per scope; everything runs
226/// whole-repo. The field exists because the alternative is a tool guessing at
227/// decomposability, and a wrong guess narrows the check silently. It is the
228/// contract a future warm charter reads, recorded honestly now while the facts
229/// are in front of us: only `cargo fmt --check` is per-file faithful (and only
230/// for a NAMED file — `cargo fmt` discovery skips cfg-gated out-of-line modules,
231/// rustfmt #4034), clippy is a whole-crate rustc driver, and cargo test targets
232/// are crate-level binaries.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum FloorScope {
236    PerFile,
237    PerCrate,
238    WholeRepo,
239}
240
241/// What a command's verdict depends on BEYOND the repo contents.
242///
243/// `network` is the honest one: `cargo deny`'s `advisories` check consults the
244/// `RustSec` DB, so the floor is not a pure function of the commit — an unchanged
245/// commit can newly fail when an advisory publishes. The verb discloses that in
246/// its output rather than letting it ambush the next unrelated PR (F69 rider).
247///
248/// `machine` is the second one, and it has a different cause: a command that
249/// measures wall-clock time answers about the machine as much as about the
250/// commit. `bench` asserts a latency threshold, so a busy machine fails a commit
251/// that passes quiet — observed as a full floor going RED at 836/1 under
252/// concurrent cargo builds and green at 837/0 on the same tree idle (F76
253/// addendum). Disclosed separately from `network` because the reasons differ and
254/// a reader who cannot tell them apart learns to skip both.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum FloorInputs {
258    Repo,
259    Toolchain,
260    Network,
261    Machine,
262}
263
264#[derive(Debug, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct FloorCommand {
267    /// Unique across the table: `--skip` and `covers_ignored_of` both address
268    /// commands by name.
269    pub name: String,
270    /// argv, never a shell string — a shell string is an injection surface and
271    /// a quoting-bug surface, and neither belongs in a gate.
272    pub run: Vec<String>,
273    pub scope: FloorScope,
274    pub inputs: FloorInputs,
275    /// The install hint a missing binary's error carries (the `db.rs`
276    /// `run_tool` pattern: an absent tool is a loud named failure, never a
277    /// silent skip).
278    pub install: Option<String>,
279    /// Whether the Stop sweep runs this command. Default `false` — the
280    /// conservative posture, and the whole Stop integration ships dark until a
281    /// human rules a command in.
282    #[serde(default)]
283    pub on_stop: bool,
284    /// Whether this command's output carries cargo-test-shaped `test result:`
285    /// lines whose ignored count must be accounted for.
286    #[serde(default)]
287    pub reconcile_ignored: bool,
288    /// Declares that THIS command executes the tests the named command reported
289    /// as ignored. The link is declared rather than guessed because the
290    /// accounting is only as trustworthy as the claim it checks.
291    pub covers_ignored_of: Option<String>,
292}
293
294#[derive(Debug, Deserialize)]
295#[serde(deny_unknown_fields)]
296struct RawManifest {
297    version: u32,
298    schema_epoch: Option<u32>,
299    canonical: String,
300    authoring: String,
301    #[serde(default)]
302    contracts: Vec<Contract>,
303    #[serde(default)]
304    mappings: Vec<Mapping>,
305    gates: Gates,
306    db: Option<Db>,
307    #[serde(default)]
308    features: Features,
309    floor: Option<Floor>,
310}
311
312/// A parsed, boundary-resolved manifest. Globs are compiled once here.
313pub struct Manifest {
314    pub version: u32,
315    /// R9 (approved 2026-08-13): the workspace-wide schema epoch, owned by
316    /// the manifest and human-incremented. The SOLE source authoring,
317    /// compile, and the daemon probe read. Absent key = 1 (pre-R9
318    /// manifests keep parsing; the repo's own manifest declares it).
319    pub schema_epoch: u32,
320    pub canonical: String,
321    pub authoring: String,
322    pub contracts: Vec<Contract>,
323    pub mappings: Vec<Mapping>,
324    pub gates: Gates,
325    pub db: Option<Db>,
326    pub features: Features,
327    /// `[floor]` — absent when the repo declares no mechanical floor. The verb
328    /// owns that refusal, not the parser.
329    pub floor: Option<Floor>,
330    mapping_globs: GlobSet,
331    protected_globs: GlobSet,
332    read_only_globs: GlobSet,
333    retrieval_globs: GlobSet,
334}
335
336impl std::fmt::Debug for Manifest {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        // GlobSet has no Debug; show the declarative fields only.
339        f.debug_struct("Manifest")
340            .field("version", &self.version)
341            .field("schema_epoch", &self.schema_epoch)
342            .field("canonical", &self.canonical)
343            .field("authoring", &self.authoring)
344            .field("contracts", &self.contracts)
345            .field("mappings", &self.mappings)
346            .field("gates", &self.gates)
347            .field("db", &self.db)
348            .field("features", &self.features)
349            .field("floor", &self.floor)
350            .finish_non_exhaustive()
351    }
352}
353
354impl Manifest {
355    /// Parses and boundary-resolves manifest text.
356    ///
357    /// # Errors
358    /// Returns `ManifestError` on TOML/schema violations (with candidate
359    /// suggestions for unknown keys), unsupported versions, undeclared
360    /// contract references, and invalid globs.
361    pub fn parse(text: &str) -> Result<Self, ManifestError> {
362        let raw: RawManifest = toml::from_str(text).map_err(|e| enrich_unknown_key(&e))?;
363
364        if raw.version != SUPPORTED_VERSION {
365            return Err(ManifestError::UnsupportedVersion {
366                found: raw.version,
367                supported: SUPPORTED_VERSION,
368            });
369        }
370        if let Some(0) = raw.schema_epoch {
371            return Err(ManifestError::NonPositiveEpoch { found: 0 });
372        }
373        resolve_contract_references(&raw)?;
374        if let Some(floor) = raw.floor.as_ref() {
375            validate_floor(floor)?;
376        }
377
378        let mapping_globs = build_globset(raw.mappings.iter().map(|m| m.glob.as_str()))?;
379        let protected_globs = build_globset(raw.gates.protected_paths.iter().map(String::as_str))?;
380        let read_only_globs = build_globset(raw.gates.read_only_paths.iter().map(String::as_str))?;
381        let retrieval_globs = build_globset(raw.gates.retrieval_paths.iter().map(String::as_str))?;
382
383        Ok(Self {
384            version: raw.version,
385            schema_epoch: raw.schema_epoch.unwrap_or(1),
386            canonical: raw.canonical,
387            authoring: raw.authoring,
388            contracts: raw.contracts,
389            mappings: raw.mappings,
390            gates: raw.gates,
391            db: raw.db,
392            features: raw.features,
393            floor: raw.floor,
394            mapping_globs,
395            protected_globs,
396            read_only_globs,
397            retrieval_globs,
398        })
399    }
400
401    /// First mapping whose glob matches `path`, if any.
402    #[must_use]
403    pub fn mapping_for(&self, path: &str) -> Option<&Mapping> {
404        self.mapping_globs
405            .matches(path)
406            .first()
407            .map(|&index| &self.mappings[index])
408    }
409
410    #[must_use]
411    pub fn is_protected(&self, path: &str) -> bool {
412        self.protected_globs.is_match(path)
413    }
414
415    /// Whether `path` falls under a `read_only_paths` glob. Committed-ness
416    /// is the caller's question (it needs git); this is only the glob half.
417    #[must_use]
418    pub fn is_read_only(&self, path: &str) -> bool {
419        self.read_only_globs.is_match(path)
420    }
421
422    /// Whether `path` falls under a `retrieval_paths` glob. Whether the
423    /// READ was bounded is the caller's question; this is only the glob
424    /// half, mirroring `is_read_only`.
425    #[must_use]
426    pub fn is_retrieval_gated(&self, path: &str) -> bool {
427        self.retrieval_globs.is_match(path)
428    }
429
430    /// The declared retrieval destination, if the manifest names one.
431    #[must_use]
432    pub fn retrieval_tool(&self) -> Option<&str> {
433        self.gates.retrieval_tool.as_deref()
434    }
435
436    /// The `[features]` git-plane switch. `true` unless the manifest
437    /// positively declares `git_hooks = false`.
438    #[must_use]
439    pub fn git_hooks_enabled(&self) -> bool {
440        self.features.git_hooks
441    }
442}
443
444fn resolve_contract_references(raw: &RawManifest) -> Result<(), ManifestError> {
445    let declared: Vec<&str> = raw.contracts.iter().map(|c| c.name.as_str()).collect();
446    for mapping in &raw.mappings {
447        for reference in &mapping.contracts {
448            if !declared.contains(&reference.as_str()) {
449                return Err(ManifestError::UnknownContract {
450                    reference: reference.as_str().to_owned(),
451                    candidates: declared.join(", "),
452                });
453            }
454        }
455    }
456    Ok(())
457}
458
459/// The `[floor]` invariants serde cannot express: names unique, every `run`
460/// non-empty, and every `covers_ignored_of` resolving to some OTHER declared
461/// command.
462///
463/// The coverage rules exist because the ignored-test accounting is only as
464/// trustworthy as the claim it checks. A dangling reference makes the accounting
465/// vacuous; self-coverage balances its arithmetic while executing nothing new.
466/// Both are the shape of defect `scripts/floor.sh` was written to prevent — a
467/// floor citation that counts less than it claims (D7(a), F62).
468fn validate_floor(floor: &Floor) -> Result<(), ManifestError> {
469    let mut seen: Vec<&str> = Vec::with_capacity(floor.commands.len());
470    for command in &floor.commands {
471        if seen.contains(&command.name.as_str()) {
472            return Err(ManifestError::DuplicateFloorCommand {
473                name: command.name.clone(),
474            });
475        }
476        seen.push(&command.name);
477        if command.run.is_empty() {
478            return Err(ManifestError::EmptyFloorRun {
479                name: command.name.clone(),
480            });
481        }
482    }
483    // Resolved by name across the WHOLE table, so a coverer may be declared
484    // before the command it covers; a forward-only scan would make the link
485    // order-dependent and the error message a lie.
486    for command in &floor.commands {
487        let Some(reference) = command.covers_ignored_of.as_deref() else {
488            continue;
489        };
490        if reference == command.name {
491            return Err(ManifestError::SelfFloorCoverage {
492                name: command.name.clone(),
493            });
494        }
495        if !seen.contains(&reference) {
496            return Err(ManifestError::UnknownFloorCoverage {
497                name: command.name.clone(),
498                reference: reference.to_owned(),
499                candidates: seen.join(", "),
500            });
501        }
502    }
503    Ok(())
504}
505
506fn build_globset<'a>(globs: impl Iterator<Item = &'a str>) -> Result<GlobSet, ManifestError> {
507    let mut builder = GlobSetBuilder::new();
508    for glob in globs {
509        let compiled = Glob::new(glob).map_err(|error| ManifestError::BadGlob {
510            glob: glob.to_owned(),
511            message: error.to_string(),
512        })?;
513        builder.add(compiled);
514    }
515    builder.build().map_err(|error| ManifestError::BadGlob {
516        glob: "<combined>".to_owned(),
517        message: error.to_string(),
518    })
519}
520
521/// Appends nearest-candidate suggestions to serde's "unknown field" errors so
522/// every rejection is a retry prompt (design principle 5).
523fn enrich_unknown_key(error: &toml::de::Error) -> ManifestError {
524    let message = error.to_string();
525    let Some(unknown) = extract_unknown_field(&message) else {
526        return ManifestError::Invalid { message };
527    };
528    let candidates = nearest_keys(&unknown);
529    if candidates.is_empty() {
530        return ManifestError::Invalid { message };
531    }
532    ManifestError::Invalid {
533        message: format!("{message}; did you mean: {}?", candidates.join(", ")),
534    }
535}
536
537fn extract_unknown_field(message: &str) -> Option<String> {
538    let marker = "unknown field `";
539    let start = message.find(marker)? + marker.len();
540    let rest = &message[start..];
541    let end = rest.find('`')?;
542    Some(rest[..end].to_owned())
543}
544
545fn nearest_keys(unknown: &str) -> Vec<&'static str> {
546    let mut scored: Vec<(usize, &'static str)> = KNOWN_KEYS
547        .iter()
548        .map(|&key| (levenshtein(unknown, key), key))
549        .filter(|&(distance, _)| distance <= 3)
550        .collect();
551    scored.sort_unstable();
552    scored.into_iter().take(3).map(|(_, key)| key).collect()
553}
554
555pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
556    let a_chars: Vec<char> = a.chars().collect();
557    let b_chars: Vec<char> = b.chars().collect();
558    let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
559    let mut current = vec![0usize; b_chars.len() + 1];
560
561    for (i, &a_char) in a_chars.iter().enumerate() {
562        current[0] = i + 1;
563        for (j, &b_char) in b_chars.iter().enumerate() {
564            let substitution = usize::from(a_char != b_char);
565            current[j + 1] = (previous[j] + substitution)
566                .min(previous[j + 1] + 1)
567                .min(current[j] + 1);
568        }
569        std::mem::swap(&mut previous, &mut current);
570    }
571    previous[b_chars.len()]
572}