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//!
15//! See `.yah/docs/architecture/mesofact.md` §"Render axis × source axis".
16
17use crate::manifest::{Manifest, Requires, RouteMode, MANIFEST_VERSION};
18use std::collections::BTreeMap;
19use thiserror::Error;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SourceScope {
23    Global,
24    Project,
25    User,
26}
27
28impl SourceScope {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            SourceScope::Global => "global",
32            SourceScope::Project => "project",
33            SourceScope::User => "user",
34        }
35    }
36}
37
38pub type SourceCatalog = BTreeMap<String, SourceScope>;
39
40#[derive(Debug, Clone, PartialEq, Eq, Error)]
41pub enum ValidationErrorKind {
42    #[error("unsupported manifest version '{got}' (expected '{}')", MANIFEST_VERSION)]
43    UnsupportedVersion { got: String },
44    #[error("source '{name}' not declared in catalog")]
45    UnknownSource { name: String },
46    #[error("Mode 1 cannot read from non-'global' source '{name}' (scope='{scope}')")]
47    Mode1ScopedSource { name: String, scope: &'static str },
48    #[error("Mode 1 cannot require 'user' (the build can't enumerate users)")]
49    Mode1RequiresUser,
50}
51
52impl ValidationErrorKind {
53    /// Snake-case label matching the TS validator's `ValidationErrorKind`.
54    /// Stable identifier used by the shared fixture suite.
55    pub fn label(&self) -> &'static str {
56        match self {
57            ValidationErrorKind::UnsupportedVersion { .. } => "unsupported_version",
58            ValidationErrorKind::UnknownSource { .. } => "unknown_source",
59            ValidationErrorKind::Mode1ScopedSource { .. } => "mode1_scoped_source",
60            ValidationErrorKind::Mode1RequiresUser => "mode1_requires_user",
61        }
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Error)]
66#[error("{path}: {kind}")]
67pub struct ValidationError {
68    pub path: String,
69    pub kind: ValidationErrorKind,
70}
71
72pub fn validate(manifest: &Manifest, catalog: &SourceCatalog) -> Result<(), Vec<ValidationError>> {
73    let mut errors = Vec::new();
74
75    if manifest.version != MANIFEST_VERSION {
76        errors.push(ValidationError {
77            path: "version".into(),
78            kind: ValidationErrorKind::UnsupportedVersion {
79                got: manifest.version.clone(),
80            },
81        });
82    }
83
84    for (idx, route) in manifest.routes.iter().enumerate() {
85        if !matches!(route.mode, RouteMode::Static) {
86            continue;
87        }
88
89        if let Some(reads) = &route.source_reads {
90            for name in reads {
91                let base = format!("routes[{idx}].source_reads");
92                match catalog.get(name) {
93                    None => errors.push(ValidationError {
94                        path: base,
95                        kind: ValidationErrorKind::UnknownSource { name: name.clone() },
96                    }),
97                    Some(SourceScope::Global) => {}
98                    Some(other) => errors.push(ValidationError {
99                        path: base,
100                        kind: ValidationErrorKind::Mode1ScopedSource {
101                            name: name.clone(),
102                            scope: other.as_str(),
103                        },
104                    }),
105                }
106            }
107        }
108
109        if let Some(requires) = &route.requires {
110            if requires.iter().any(|r| matches!(r, Requires::User)) {
111                errors.push(ValidationError {
112                    path: format!("routes[{idx}].requires"),
113                    kind: ValidationErrorKind::Mode1RequiresUser,
114                });
115            }
116        }
117    }
118
119    if errors.is_empty() {
120        Ok(())
121    } else {
122        Err(errors)
123    }
124}