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    "db",
30    "direction",
31    "provider",
32    "rls_tests",
33];
34
35#[derive(Debug, Error)]
36pub enum ManifestError {
37    #[error("manifest is not valid TOML or violates the schema: {message}")]
38    Invalid { message: String },
39    #[error("manifest version {found} is unsupported (this binary supports {supported})")]
40    UnsupportedVersion { found: u32, supported: u32 },
41    #[error(
42        "mapping references undeclared contract '{reference}'; declared contracts: {candidates}"
43    )]
44    UnknownContract {
45        reference: String,
46        candidates: String,
47    },
48    #[error("glob '{glob}' is invalid: {message}")]
49    BadGlob { glob: String, message: String },
50    #[error(
51        "schema_epoch must be a positive integer (a human increments it on \
52         epoch-sensitive change, R9); found {found}"
53    )]
54    NonPositiveEpoch { found: u32 },
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
58#[serde(transparent)]
59pub struct ContractName(String);
60
61impl ContractName {
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68#[derive(Debug, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct Contract {
71    pub name: ContractName,
72    pub source: String,
73    pub emit: Vec<String>,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Mapping {
79    pub glob: String,
80    pub contracts: Vec<ContractName>,
81    pub require: Option<String>,
82}
83
84#[derive(Debug, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Gates {
87    pub suppression_comments: Option<String>,
88    #[serde(default)]
89    pub protected_paths: Vec<String>,
90    /// Globs whose COMMITTED files are read-only to agents: new files may
91    /// be created (the RED-suite authoring window), files in git HEAD may
92    /// not be modified — N10 ("committed first, read-only hereafter") as a
93    /// product gate. Unwaivable, like `protected_paths`.
94    #[serde(default)]
95    pub read_only_paths: Vec<String>,
96}
97
98/// `[db]` (spec §5.3, §10): drift-gate configuration. `direction` names
99/// the source of truth — "contract" (generated DDL is desired state) or
100/// "database" (introspected schema is; contracts must follow).
101#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct Db {
104    pub direction: DbDirection,
105    pub provider: Option<String>,
106    pub rls_tests: Option<String>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum DbDirection {
112    Contract,
113    Database,
114}
115
116#[derive(Debug, Deserialize)]
117#[serde(deny_unknown_fields)]
118struct RawManifest {
119    version: u32,
120    schema_epoch: Option<u32>,
121    canonical: String,
122    authoring: String,
123    #[serde(default)]
124    contracts: Vec<Contract>,
125    #[serde(default)]
126    mappings: Vec<Mapping>,
127    gates: Gates,
128    db: Option<Db>,
129}
130
131/// A parsed, boundary-resolved manifest. Globs are compiled once here.
132pub struct Manifest {
133    pub version: u32,
134    /// R9 (approved 2026-08-13): the workspace-wide schema epoch, owned by
135    /// the manifest and human-incremented. The SOLE source authoring,
136    /// compile, and the daemon probe read. Absent key = 1 (pre-R9
137    /// manifests keep parsing; the repo's own manifest declares it).
138    pub schema_epoch: u32,
139    pub canonical: String,
140    pub authoring: String,
141    pub contracts: Vec<Contract>,
142    pub mappings: Vec<Mapping>,
143    pub gates: Gates,
144    pub db: Option<Db>,
145    mapping_globs: GlobSet,
146    protected_globs: GlobSet,
147    read_only_globs: GlobSet,
148}
149
150impl std::fmt::Debug for Manifest {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        // GlobSet has no Debug; show the declarative fields only.
153        f.debug_struct("Manifest")
154            .field("version", &self.version)
155            .field("schema_epoch", &self.schema_epoch)
156            .field("canonical", &self.canonical)
157            .field("authoring", &self.authoring)
158            .field("contracts", &self.contracts)
159            .field("mappings", &self.mappings)
160            .field("gates", &self.gates)
161            .field("db", &self.db)
162            .finish_non_exhaustive()
163    }
164}
165
166impl Manifest {
167    /// Parses and boundary-resolves manifest text.
168    ///
169    /// # Errors
170    /// Returns `ManifestError` on TOML/schema violations (with candidate
171    /// suggestions for unknown keys), unsupported versions, undeclared
172    /// contract references, and invalid globs.
173    pub fn parse(text: &str) -> Result<Self, ManifestError> {
174        let raw: RawManifest = toml::from_str(text).map_err(|e| enrich_unknown_key(&e))?;
175
176        if raw.version != SUPPORTED_VERSION {
177            return Err(ManifestError::UnsupportedVersion {
178                found: raw.version,
179                supported: SUPPORTED_VERSION,
180            });
181        }
182        if let Some(0) = raw.schema_epoch {
183            return Err(ManifestError::NonPositiveEpoch { found: 0 });
184        }
185        resolve_contract_references(&raw)?;
186
187        let mapping_globs = build_globset(raw.mappings.iter().map(|m| m.glob.as_str()))?;
188        let protected_globs = build_globset(raw.gates.protected_paths.iter().map(String::as_str))?;
189        let read_only_globs = build_globset(raw.gates.read_only_paths.iter().map(String::as_str))?;
190
191        Ok(Self {
192            version: raw.version,
193            schema_epoch: raw.schema_epoch.unwrap_or(1),
194            canonical: raw.canonical,
195            authoring: raw.authoring,
196            contracts: raw.contracts,
197            mappings: raw.mappings,
198            gates: raw.gates,
199            db: raw.db,
200            mapping_globs,
201            protected_globs,
202            read_only_globs,
203        })
204    }
205
206    /// First mapping whose glob matches `path`, if any.
207    #[must_use]
208    pub fn mapping_for(&self, path: &str) -> Option<&Mapping> {
209        self.mapping_globs
210            .matches(path)
211            .first()
212            .map(|&index| &self.mappings[index])
213    }
214
215    #[must_use]
216    pub fn is_protected(&self, path: &str) -> bool {
217        self.protected_globs.is_match(path)
218    }
219
220    /// Whether `path` falls under a `read_only_paths` glob. Committed-ness
221    /// is the caller's question (it needs git); this is only the glob half.
222    #[must_use]
223    pub fn is_read_only(&self, path: &str) -> bool {
224        self.read_only_globs.is_match(path)
225    }
226}
227
228fn resolve_contract_references(raw: &RawManifest) -> Result<(), ManifestError> {
229    let declared: Vec<&str> = raw.contracts.iter().map(|c| c.name.as_str()).collect();
230    for mapping in &raw.mappings {
231        for reference in &mapping.contracts {
232            if !declared.contains(&reference.as_str()) {
233                return Err(ManifestError::UnknownContract {
234                    reference: reference.as_str().to_owned(),
235                    candidates: declared.join(", "),
236                });
237            }
238        }
239    }
240    Ok(())
241}
242
243fn build_globset<'a>(globs: impl Iterator<Item = &'a str>) -> Result<GlobSet, ManifestError> {
244    let mut builder = GlobSetBuilder::new();
245    for glob in globs {
246        let compiled = Glob::new(glob).map_err(|error| ManifestError::BadGlob {
247            glob: glob.to_owned(),
248            message: error.to_string(),
249        })?;
250        builder.add(compiled);
251    }
252    builder.build().map_err(|error| ManifestError::BadGlob {
253        glob: "<combined>".to_owned(),
254        message: error.to_string(),
255    })
256}
257
258/// Appends nearest-candidate suggestions to serde's "unknown field" errors so
259/// every rejection is a retry prompt (design principle 5).
260fn enrich_unknown_key(error: &toml::de::Error) -> ManifestError {
261    let message = error.to_string();
262    let Some(unknown) = extract_unknown_field(&message) else {
263        return ManifestError::Invalid { message };
264    };
265    let candidates = nearest_keys(&unknown);
266    if candidates.is_empty() {
267        return ManifestError::Invalid { message };
268    }
269    ManifestError::Invalid {
270        message: format!("{message}; did you mean: {}?", candidates.join(", ")),
271    }
272}
273
274fn extract_unknown_field(message: &str) -> Option<String> {
275    let marker = "unknown field `";
276    let start = message.find(marker)? + marker.len();
277    let rest = &message[start..];
278    let end = rest.find('`')?;
279    Some(rest[..end].to_owned())
280}
281
282fn nearest_keys(unknown: &str) -> Vec<&'static str> {
283    let mut scored: Vec<(usize, &'static str)> = KNOWN_KEYS
284        .iter()
285        .map(|&key| (levenshtein(unknown, key), key))
286        .filter(|&(distance, _)| distance <= 3)
287        .collect();
288    scored.sort_unstable();
289    scored.into_iter().take(3).map(|(_, key)| key).collect()
290}
291
292pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
293    let a_chars: Vec<char> = a.chars().collect();
294    let b_chars: Vec<char> = b.chars().collect();
295    let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
296    let mut current = vec![0usize; b_chars.len() + 1];
297
298    for (i, &a_char) in a_chars.iter().enumerate() {
299        current[0] = i + 1;
300        for (j, &b_char) in b_chars.iter().enumerate() {
301            let substitution = usize::from(a_char != b_char);
302            current[j + 1] = (previous[j] + substitution)
303                .min(previous[j + 1] + 1)
304                .min(current[j] + 1);
305        }
306        std::mem::swap(&mut previous, &mut current);
307    }
308    previous[b_chars.len()]
309}