Skip to main content

mesofact_core/
validate.rs

1//! Manifest validator — semantic rules over a parsed [`Manifest`]. Mirrors
2//! `packages/mesofact-runtime/src/validate.ts`. Used by the proxy on boot
3//! (refuse a malformed manifest, keep the old one live) and by the build to
4//! reject forbidden shapes before HTML hits R2.
5//!
6//! Structural validation comes "for free" from serde — call [`validate`] only
7//! after `serde_json::from_str::<Manifest>(...)` succeeds.
8//!
9//! Rules enforced:
10//! - **R1** (`Mode1ScopedSource`) — a `Mode::Static` route whose
11//!   `source_reads` names any non-`global` source is rejected.
12//! - **R2** (`Mode1RequiresUser`) — a `Mode::Static` route whose `requires`
13//!   contains [`Requires::User`] is rejected.
14//! - **R3** (`UnknownHook`) — a `hooks` entry naming a hook the engine does
15//!   not invoke is rejected (R756-F6 / W311 §2).
16//!
17//! See `.yah/docs/architecture/mesofact.md` §"Render axis × source axis".
18
19use crate::manifest::{Manifest, Requires, RouteMode, MANIFEST_VERSION};
20use std::collections::BTreeMap;
21use thiserror::Error;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SourceScope {
25    Global,
26    Project,
27    User,
28}
29
30impl SourceScope {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            SourceScope::Global => "global",
34            SourceScope::Project => "project",
35            SourceScope::User => "user",
36        }
37    }
38}
39
40pub type SourceCatalog = BTreeMap<String, SourceScope>;
41
42#[derive(Debug, Clone, PartialEq, Eq, Error)]
43pub enum ValidationErrorKind {
44    #[error("unsupported manifest version '{got}' (expected '{}')", MANIFEST_VERSION)]
45    UnsupportedVersion { got: String },
46    #[error("source '{name}' not declared in catalog")]
47    UnknownSource { name: String },
48    #[error("Mode 1 cannot read from non-'global' source '{name}' (scope='{scope}')")]
49    Mode1ScopedSource { name: String, scope: &'static str },
50    #[error("Mode 1 cannot require 'user' (the build can't enumerate users)")]
51    Mode1RequiresUser,
52    /// R756-F6 — the Mode 2 hook name set is closed. Only the engine invokes
53    /// hooks, so a name it does not know names a bundle nothing will ever
54    /// call: a typo that would otherwise fail silently at runtime as "this
55    /// app contributed no verdict".
56    #[error("unknown hook '{name}' (known: {})", HOOK_NAMES.join(", "))]
57    UnknownHook { name: String },
58}
59
60/// Every Mode 2 hook name the engine knows how to invoke. Mirrors
61/// `HOOK_NAMES` in `packages/mesofact-runtime/src/hooks.ts` and
62/// `mesofact_render::route_config`.
63pub const HOOK_NAMES: &[&str] = &["readyz"];
64
65impl ValidationErrorKind {
66    /// Snake-case label matching the TS validator's `ValidationErrorKind`.
67    /// Stable identifier used by the shared fixture suite.
68    pub fn label(&self) -> &'static str {
69        match self {
70            ValidationErrorKind::UnsupportedVersion { .. } => "unsupported_version",
71            ValidationErrorKind::UnknownSource { .. } => "unknown_source",
72            ValidationErrorKind::Mode1ScopedSource { .. } => "mode1_scoped_source",
73            ValidationErrorKind::Mode1RequiresUser => "mode1_requires_user",
74            ValidationErrorKind::UnknownHook { .. } => "unknown_hook",
75        }
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Error)]
80#[error("{path}: {kind}")]
81pub struct ValidationError {
82    pub path: String,
83    pub kind: ValidationErrorKind,
84}
85
86pub fn validate(manifest: &Manifest, catalog: &SourceCatalog) -> Result<(), Vec<ValidationError>> {
87    let mut errors = Vec::new();
88
89    if manifest.version != MANIFEST_VERSION {
90        errors.push(ValidationError {
91            path: "version".into(),
92            kind: ValidationErrorKind::UnsupportedVersion {
93                got: manifest.version.clone(),
94            },
95        });
96    }
97
98    for (idx, route) in manifest.routes.iter().enumerate() {
99        if !matches!(route.mode, RouteMode::Static) {
100            continue;
101        }
102
103        if let Some(reads) = &route.source_reads {
104            for name in reads {
105                let base = format!("routes[{idx}].source_reads");
106                match catalog.get(name) {
107                    None => errors.push(ValidationError {
108                        path: base,
109                        kind: ValidationErrorKind::UnknownSource { name: name.clone() },
110                    }),
111                    Some(SourceScope::Global) => {}
112                    Some(other) => errors.push(ValidationError {
113                        path: base,
114                        kind: ValidationErrorKind::Mode1ScopedSource {
115                            name: name.clone(),
116                            scope: other.as_str(),
117                        },
118                    }),
119                }
120            }
121        }
122
123        if let Some(requires) = &route.requires {
124            if requires.iter().any(|r| matches!(r, Requires::User)) {
125                errors.push(ValidationError {
126                    path: format!("routes[{idx}].requires"),
127                    kind: ValidationErrorKind::Mode1RequiresUser,
128                });
129            }
130        }
131    }
132
133    // R3 (R756-F6) — every declared Mode 2 hook must be a name the engine
134    // knows how to invoke.
135    if let Some(hooks) = &manifest.hooks {
136        for name in hooks.keys() {
137            if !HOOK_NAMES.contains(&name.as_str()) {
138                errors.push(ValidationError {
139                    path: format!("hooks.{name}"),
140                    kind: ValidationErrorKind::UnknownHook { name: name.clone() },
141                });
142            }
143        }
144    }
145
146    if errors.is_empty() {
147        Ok(())
148    } else {
149        Err(errors)
150    }
151}