1use std::sync::OnceLock;
10
11use jsonschema::{Registry, Validator};
12use mif_problem::{
13 Applicability, CodeAction, ProblemDetails, ProblemMeta, SuggestedFix, ToProblem,
14};
15use serde_json::Value;
16
17const MIF_SCHEMA: &str = include_str!("schemas/mif.schema.json");
18const CITATION_SCHEMA: &str = include_str!("schemas/citation.schema.json");
19const ONTOLOGY_SCHEMA: &str = include_str!("schemas/ontology.schema.json");
20const ENTITY_REFERENCE_SCHEMA: &str =
21 include_str!("schemas/definitions/entity-reference.schema.json");
22const ENTITY_REFERENCE_SCHEMA_ID: &str =
23 "https://mif-spec.dev/schema/definitions/entity-reference.schema.json";
24
25#[derive(Debug)]
36pub struct SchemaCompilationSource(String);
37
38impl std::fmt::Display for SchemaCompilationSource {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.write_str(&self.0)
41 }
42}
43
44impl std::error::Error for SchemaCompilationSource {}
45
46#[derive(Debug, thiserror::Error)]
48pub enum MifSchemaError {
49 #[error("internal error: vendored MIF schema failed to compile: {0}")]
53 SchemaCompilation(#[source] SchemaCompilationSource),
54 #[error("MIF document failed schema validation: {}", .0.join("; "))]
56 Invalid(Vec<String>),
57}
58
59impl MifSchemaError {
60 #[must_use]
63 pub fn messages(&self) -> &[String] {
64 match self {
65 Self::Invalid(errors) => errors,
66 Self::SchemaCompilation(_) => &[],
67 }
68 }
69
70 const fn meta(&self) -> ProblemMeta {
71 match self {
72 Self::SchemaCompilation(_) => ProblemMeta {
73 slug: "schema-compilation",
74 version: "v1",
75 title: "Internal schema compilation error",
76 status: 500,
77 exit_code: 1,
78 },
79 Self::Invalid(_) => ProblemMeta {
80 slug: "invalid-document",
81 version: "v1",
82 title: "Document failed schema validation",
83 status: 422,
84 exit_code: 2,
85 },
86 }
87 }
88}
89
90impl ToProblem for MifSchemaError {
91 fn to_problem(&self) -> ProblemDetails {
92 let (fix, action) = match self {
93 Self::SchemaCompilation(_) => (
94 SuggestedFix::new(
95 "This indicates a bug in mif-schema's vendored schema files, not the \
96 instance being validated. Report it upstream.",
97 Applicability::Unspecified,
98 ),
99 CodeAction::new(
100 "File a bug against mif-schema's vendored schemas",
101 "quickfix",
102 Applicability::Unspecified,
103 ),
104 ),
105 Self::Invalid(_) => (
106 SuggestedFix::new(
107 "Correct the document so it conforms to the canonical MIF JSON Schema, \
108 then retry.",
109 Applicability::MaybeIncorrect,
110 ),
111 CodeAction::new(
112 "Fix the reported schema violations",
113 "quickfix",
114 Applicability::MaybeIncorrect,
115 ),
116 ),
117 };
118 self.meta()
119 .into_details(env!("CARGO_PKG_NAME"), self.to_string())
120 .with_suggested_fix(fix)
121 .with_code_action(action)
122 }
123}
124
125fn build_registry() -> Result<Registry<'static>, String> {
126 let entity_reference: Value =
127 serde_json::from_str(ENTITY_REFERENCE_SCHEMA).map_err(|e| e.to_string())?;
128 Registry::new()
129 .add(ENTITY_REFERENCE_SCHEMA_ID, entity_reference)
130 .map_err(|e| e.to_string())?
131 .prepare()
132 .map_err(|e| e.to_string())
133}
134
135fn build_validator(schema_json: &str) -> Result<Validator, String> {
136 let schema: Value = serde_json::from_str(schema_json).map_err(|e| e.to_string())?;
137 let registry = build_registry()?;
138 jsonschema::options()
139 .with_registry(®istry)
140 .build(&schema)
141 .map_err(|e| e.to_string())
142}
143
144fn document_validator() -> Result<&'static Validator, MifSchemaError> {
145 static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
146 VALIDATOR
147 .get_or_init(|| build_validator(MIF_SCHEMA))
148 .as_ref()
149 .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
150}
151
152fn citation_validator() -> Result<&'static Validator, MifSchemaError> {
153 static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
154 VALIDATOR
155 .get_or_init(|| build_validator(CITATION_SCHEMA))
156 .as_ref()
157 .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
158}
159
160fn ontology_validator() -> Result<&'static Validator, MifSchemaError> {
161 static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
162 VALIDATOR
163 .get_or_init(|| build_validator(ONTOLOGY_SCHEMA))
164 .as_ref()
165 .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
166}
167
168fn validate(validator: &Validator, instance: &Value) -> Result<(), MifSchemaError> {
169 let errors: Vec<String> = validator
170 .iter_errors(instance)
171 .map(|error| error.to_string())
172 .collect();
173 if errors.is_empty() {
174 Ok(())
175 } else {
176 Err(MifSchemaError::Invalid(errors))
177 }
178}
179
180pub fn validate_document(instance: &Value) -> Result<(), MifSchemaError> {
190 validate(document_validator()?, instance)
191}
192
193pub fn validate_citation(instance: &Value) -> Result<(), MifSchemaError> {
199 validate(citation_validator()?, instance)
200}
201
202pub fn validate_ontology_definition(instance: &Value) -> Result<(), MifSchemaError> {
208 validate(ontology_validator()?, instance)
209}
210
211#[cfg(test)]
212mod tests {
213 use mif_problem::ToProblem;
214 use serde_json::json;
215
216 use super::{
217 MifSchemaError, SchemaCompilationSource, validate_citation, validate_document,
218 validate_ontology_definition,
219 };
220
221 fn minimal_valid_document() -> serde_json::Value {
222 json!({
223 "@context": "https://mif-spec.dev/schema/context.jsonld",
224 "@type": "Concept",
225 "@id": "urn:mif:memory:test-001",
226 "conceptType": "semantic",
227 "content": "Test content.",
228 "created": "2026-07-02T00:00:00Z",
229 })
230 }
231
232 #[test]
233 fn valid_document_passes() {
234 assert!(validate_document(&minimal_valid_document()).is_ok());
235 }
236
237 #[test]
238 fn document_missing_required_field_fails() {
239 let mut instance = minimal_valid_document();
240 instance.as_object_mut().unwrap().remove("conceptType");
241 let result = validate_document(&instance);
242 assert!(result.is_err());
243 }
244
245 #[test]
246 fn messages_reports_the_invalid_variant_and_is_empty_for_schema_compilation() {
247 let mut instance = minimal_valid_document();
248 instance.as_object_mut().unwrap().remove("conceptType");
249 let error = validate_document(&instance).unwrap_err();
250 assert!(matches!(error, MifSchemaError::Invalid(_)));
251 assert!(!error.messages().is_empty());
252
253 let compilation_error = MifSchemaError::SchemaCompilation(SchemaCompilationSource(
254 "synthetic failure for coverage".to_string(),
255 ));
256 assert!(compilation_error.messages().is_empty());
257 }
258
259 #[test]
260 fn document_with_bad_id_pattern_fails() {
261 let mut instance = minimal_valid_document();
262 instance["@id"] = json!("not-a-urn");
263 assert!(validate_document(&instance).is_err());
264 }
265
266 #[test]
267 fn document_with_entity_reference_resolves_ref_chain() {
268 let mut instance = minimal_valid_document();
269 instance["entities"] = json!([{
270 "@type": "EntityReference",
271 "entity": { "@id": "urn:mif:entity:person:jane-smith" },
272 "entityType": "Person",
273 }]);
274 assert!(validate_document(&instance).is_ok());
275 }
276
277 #[test]
278 fn document_with_invalid_entity_reference_fails() {
279 let mut instance = minimal_valid_document();
280 instance["entities"] = json!([{
281 "@type": "EntityReference",
282 "entity": { "@id": "not-a-urn" },
283 }]);
284 assert!(validate_document(&instance).is_err());
285 }
286
287 #[test]
288 fn valid_citation_passes() {
289 let citation = json!({
290 "@type": "Citation",
291 "citationType": "documentation",
292 "citationRole": "source",
293 "title": "MIF Specification",
294 "url": "https://mif-spec.dev",
295 });
296 assert!(validate_citation(&citation).is_ok());
297 }
298
299 #[test]
300 fn valid_ontology_definition_passes() {
301 let ontology = json!({
302 "ontology": {
303 "id": "mif-base",
304 "version": "1.0.0",
305 }
306 });
307 assert!(validate_ontology_definition(&ontology).is_ok());
308 }
309
310 #[test]
311 fn ontology_definition_with_bad_id_pattern_fails() {
312 let ontology = json!({
313 "ontology": {
314 "id": "Not_Valid",
315 "version": "1.0.0",
316 }
317 });
318 assert!(validate_ontology_definition(&ontology).is_err());
319 }
320
321 #[test]
322 fn entity_type_with_classification_fields_passes() {
323 let ontology = json!({
325 "ontology": {
326 "id": "sec-fixture",
327 "version": "1.1.0",
328 },
329 "entity_types": [{
330 "name": "control",
331 "base": "semantic",
332 "aliases": ["safeguard", "countermeasure"],
333 "exemplars": ["Enforce MFA for all administrative access"],
334 "negative_examples": ["An incident report describing a control failure"],
335 }]
336 });
337 assert!(validate_ontology_definition(&ontology).is_ok());
338 }
339
340 #[test]
341 fn entity_type_classification_fields_reject_non_string_and_empty_items() {
342 let non_string_alias = json!({
343 "ontology": { "id": "sec-fixture", "version": "1.1.0" },
344 "entity_types": [{
345 "name": "control",
346 "base": "semantic",
347 "aliases": [123],
348 }]
349 });
350 assert!(validate_ontology_definition(&non_string_alias).is_err());
351
352 let empty_exemplar = json!({
353 "ontology": { "id": "sec-fixture", "version": "1.1.0" },
354 "entity_types": [{
355 "name": "control",
356 "base": "semantic",
357 "exemplars": [""],
358 }]
359 });
360 assert!(validate_ontology_definition(&empty_exemplar).is_err());
361 }
362
363 #[test]
364 fn citation_missing_required_field_fails() {
365 let citation = json!({
366 "@type": "Citation",
367 "citationType": "documentation",
368 "citationRole": "source",
369 "title": "MIF Specification",
370 });
371 assert!(validate_citation(&citation).is_err());
372 }
373
374 #[test]
375 fn invalid_document_maps_to_versioned_problem_details() {
376 let mut instance = minimal_valid_document();
377 instance.as_object_mut().unwrap().remove("conceptType");
378 let error = validate_document(&instance).unwrap_err();
379 let problem = error.to_problem();
380
381 assert_eq!(
382 problem.problem_type,
383 "https://modeled-information-format.github.io/mif-rs/references/errors/invalid-document/v1"
384 );
385 assert_eq!(problem.status, 422);
386 assert_eq!(problem.exit_code, Some(2));
387 assert!(problem.suggested_fix.is_some());
388 assert_eq!(problem.code_actions.len(), 1);
389 assert!(problem.detail.contains("schema validation"));
390 }
391
392 #[test]
393 fn schema_compilation_error_maps_to_distinct_problem_type() {
394 let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
395 let problem = error.to_problem();
396
397 assert_eq!(
398 problem.problem_type,
399 "https://modeled-information-format.github.io/mif-rs/references/errors/schema-compilation/v1"
400 );
401 assert_eq!(problem.status, 500);
402 assert_eq!(problem.exit_code, Some(1));
403 }
404
405 #[test]
406 fn schema_compilation_error_source_preserves_the_underlying_cause() {
407 let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
408
409 let source = std::error::Error::source(&error).expect("source should not be None");
410 assert_eq!(source.to_string(), "boom");
411 }
412}