1use globset::{Glob, GlobSet, GlobSetBuilder};
7use serde::Deserialize;
8use thiserror::Error;
9
10pub const SUPPORTED_VERSION: u32 = 1;
11
12const 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 #[serde(default)]
135 pub read_only_paths: Vec<String>,
136 #[serde(default)]
148 pub retrieval_paths: Vec<String>,
149 pub retrieval_tool: Option<String>,
153}
154
155#[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#[derive(Debug, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct Features {
183 #[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#[derive(Debug, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct Floor {
217 #[serde(default)]
220 pub commands: Vec<FloorCommand>,
221}
222
223#[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#[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 pub name: String,
270 pub run: Vec<String>,
273 pub scope: FloorScope,
274 pub inputs: FloorInputs,
275 pub install: Option<String>,
279 #[serde(default)]
283 pub on_stop: bool,
284 #[serde(default)]
287 pub reconcile_ignored: bool,
288 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
312pub struct Manifest {
314 pub version: u32,
315 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 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 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 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 #[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 #[must_use]
418 pub fn is_read_only(&self, path: &str) -> bool {
419 self.read_only_globs.is_match(path)
420 }
421
422 #[must_use]
426 pub fn is_retrieval_gated(&self, path: &str) -> bool {
427 self.retrieval_globs.is_match(path)
428 }
429
430 #[must_use]
432 pub fn retrieval_tool(&self) -> Option<&str> {
433 self.gates.retrieval_tool.as_deref()
434 }
435
436 #[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
459fn 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 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
521fn 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}