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, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Level {
56 L1,
58 L2,
60 L3,
62}
63
64impl Level {
65 const fn as_u8(self) -> u8 {
66 match self {
67 Self::L1 => 1,
68 Self::L2 => 2,
69 Self::L3 => 3,
70 }
71 }
72}
73
74impl std::fmt::Display for Level {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(f, "L{}", self.as_u8())
77 }
78}
79
80impl TryFrom<u8> for Level {
81 type Error = MifSchemaError;
82
83 fn try_from(value: u8) -> Result<Self, Self::Error> {
89 match value {
90 1 => Ok(Self::L1),
91 2 => Ok(Self::L2),
92 3 => Ok(Self::L3),
93 other => Err(MifSchemaError::UnsupportedLevel(other)),
94 }
95 }
96}
97
98#[derive(Debug, thiserror::Error)]
100pub enum MifSchemaError {
101 #[error("internal error: vendored MIF schema failed to compile: {0}")]
105 SchemaCompilation(#[source] SchemaCompilationSource),
106 #[error("MIF document failed schema validation: {}", .0.join("; "))]
108 Invalid(Vec<String>),
109 #[error(
112 "document does not satisfy the {level} level floor: missing {}",
113 .missing.join(", ")
114 )]
115 LevelFloorViolation {
116 level: Level,
118 missing: Vec<String>,
121 },
122 #[error("unsupported MIF level: {0} (must be 1, 2, or 3)")]
124 UnsupportedLevel(u8),
125}
126
127impl MifSchemaError {
128 #[must_use]
134 pub fn messages(&self) -> &[String] {
135 match self {
136 Self::Invalid(errors)
137 | Self::LevelFloorViolation {
138 missing: errors, ..
139 } => errors,
140 Self::SchemaCompilation(_) | Self::UnsupportedLevel(_) => &[],
141 }
142 }
143
144 const fn meta(&self) -> ProblemMeta {
145 match self {
146 Self::SchemaCompilation(_) => ProblemMeta {
147 slug: "schema-compilation",
148 version: "v1",
149 title: "Internal schema compilation error",
150 status: 500,
151 exit_code: 1,
152 },
153 Self::Invalid(_) => ProblemMeta {
154 slug: "invalid-document",
155 version: "v1",
156 title: "Document failed schema validation",
157 status: 422,
158 exit_code: 2,
159 },
160 Self::LevelFloorViolation { .. } => ProblemMeta {
161 slug: "level-floor-violation",
162 version: "v1",
163 title: "Document does not satisfy the requested level floor",
164 status: 422,
165 exit_code: 5,
166 },
167 Self::UnsupportedLevel(_) => ProblemMeta {
168 slug: "unsupported-level",
169 version: "v1",
170 title: "Unsupported MIF level",
171 status: 400,
172 exit_code: 2,
173 },
174 }
175 }
176}
177
178impl ToProblem for MifSchemaError {
179 fn to_problem(&self) -> ProblemDetails {
180 let (fix, action) = match self {
181 Self::SchemaCompilation(_) => (
182 SuggestedFix::new(
183 "This indicates a bug in mif-schema's vendored schema files, not the \
184 instance being validated. Report it upstream.",
185 Applicability::Unspecified,
186 ),
187 CodeAction::new(
188 "File a bug against mif-schema's vendored schemas",
189 "quickfix",
190 Applicability::Unspecified,
191 ),
192 ),
193 Self::Invalid(_) => (
194 SuggestedFix::new(
195 "Correct the document so it conforms to the canonical MIF JSON Schema, \
196 then retry.",
197 Applicability::MaybeIncorrect,
198 ),
199 CodeAction::new(
200 "Fix the reported schema violations",
201 "quickfix",
202 Applicability::MaybeIncorrect,
203 ),
204 ),
205 Self::LevelFloorViolation { .. } => (
206 SuggestedFix::new(
207 "Add the missing fields the requested level floor requires, then retry.",
208 Applicability::MaybeIncorrect,
209 ),
210 CodeAction::new(
211 "Add the missing level-floor fields",
212 "quickfix",
213 Applicability::MaybeIncorrect,
214 ),
215 ),
216 Self::UnsupportedLevel(_) => (
217 SuggestedFix::new(
218 "Request level 1, 2, or 3.",
219 Applicability::MachineApplicable,
220 ),
221 CodeAction::new(
222 "Use a supported level (1, 2, or 3)",
223 "quickfix",
224 Applicability::MachineApplicable,
225 ),
226 ),
227 };
228 self.meta()
229 .into_details(env!("CARGO_PKG_NAME"), self.to_string())
230 .with_suggested_fix(fix)
231 .with_code_action(action)
232 }
233}
234
235fn build_registry() -> Result<Registry<'static>, String> {
236 let entity_reference: Value =
237 serde_json::from_str(ENTITY_REFERENCE_SCHEMA).map_err(|e| e.to_string())?;
238 Registry::new()
239 .add(ENTITY_REFERENCE_SCHEMA_ID, entity_reference)
240 .map_err(|e| e.to_string())?
241 .prepare()
242 .map_err(|e| e.to_string())
243}
244
245fn build_validator(schema_json: &str) -> Result<Validator, String> {
246 let schema: Value = serde_json::from_str(schema_json).map_err(|e| e.to_string())?;
247 let registry = build_registry()?;
248 jsonschema::options()
249 .with_registry(®istry)
250 .build(&schema)
251 .map_err(|e| e.to_string())
252}
253
254fn document_validator() -> Result<&'static Validator, MifSchemaError> {
255 static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
256 VALIDATOR
257 .get_or_init(|| build_validator(MIF_SCHEMA))
258 .as_ref()
259 .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
260}
261
262fn citation_validator() -> Result<&'static Validator, MifSchemaError> {
263 static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
264 VALIDATOR
265 .get_or_init(|| build_validator(CITATION_SCHEMA))
266 .as_ref()
267 .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
268}
269
270fn ontology_validator() -> Result<&'static Validator, MifSchemaError> {
271 static VALIDATOR: OnceLock<Result<Validator, String>> = OnceLock::new();
272 VALIDATOR
273 .get_or_init(|| build_validator(ONTOLOGY_SCHEMA))
274 .as_ref()
275 .map_err(|e| MifSchemaError::SchemaCompilation(SchemaCompilationSource(e.clone())))
276}
277
278fn validate(validator: &Validator, instance: &Value) -> Result<(), MifSchemaError> {
279 let errors: Vec<String> = validator
280 .iter_errors(instance)
281 .map(|error| error.to_string())
282 .collect();
283 if errors.is_empty() {
284 Ok(())
285 } else {
286 Err(MifSchemaError::Invalid(errors))
287 }
288}
289
290pub fn validate_document(instance: &Value) -> Result<(), MifSchemaError> {
300 validate(document_validator()?, instance)
301}
302
303fn has_non_null_field(instance: &Value, key: &str) -> bool {
305 instance.get(key).is_some_and(|value| !value.is_null())
306}
307
308fn missing_level_fields(instance: &Value, level: Level) -> Vec<String> {
312 let mut missing = Vec::new();
313 if level.as_u8() >= Level::L2.as_u8() {
314 for field in ["namespace", "modified", "temporal"] {
315 if !has_non_null_field(instance, field) {
316 missing.push(field.to_string());
317 }
318 }
319 }
320 if level.as_u8() >= Level::L3.as_u8() {
321 if !has_non_null_field(instance, "provenance") {
322 missing.push("provenance".to_string());
323 }
324 let has_valid_from = instance
325 .get("temporal")
326 .and_then(|temporal| temporal.get("validFrom"))
327 .is_some_and(|value| !value.is_null());
328 if !has_valid_from {
329 missing.push("temporal.validFrom".to_string());
330 }
331 }
332 missing
333}
334
335pub fn validate_level(instance: &Value, level: Level) -> Result<(), MifSchemaError> {
346 validate_document(instance)?;
347 let missing = missing_level_fields(instance, level);
348 if missing.is_empty() {
349 Ok(())
350 } else {
351 Err(MifSchemaError::LevelFloorViolation { level, missing })
352 }
353}
354
355pub fn validate_citation(instance: &Value) -> Result<(), MifSchemaError> {
361 validate(citation_validator()?, instance)
362}
363
364pub fn validate_ontology_definition(instance: &Value) -> Result<(), MifSchemaError> {
370 validate(ontology_validator()?, instance)
371}
372
373#[cfg(test)]
374mod tests {
375 use mif_problem::ToProblem;
376 use serde_json::json;
377
378 use super::{
379 Level, MifSchemaError, SchemaCompilationSource, validate_citation, validate_document,
380 validate_level, validate_ontology_definition,
381 };
382
383 fn minimal_valid_document() -> serde_json::Value {
384 json!({
385 "@context": "https://mif-spec.dev/schema/context.jsonld",
386 "@type": "Concept",
387 "@id": "urn:mif:memory:test-001",
388 "conceptType": "semantic",
389 "content": "Test content.",
390 "created": "2026-07-02T00:00:00Z",
391 })
392 }
393
394 #[test]
395 fn valid_document_passes() {
396 assert!(validate_document(&minimal_valid_document()).is_ok());
397 }
398
399 #[test]
400 fn document_missing_required_field_fails() {
401 let mut instance = minimal_valid_document();
402 instance.as_object_mut().unwrap().remove("conceptType");
403 let result = validate_document(&instance);
404 assert!(result.is_err());
405 }
406
407 #[test]
408 fn messages_reports_the_invalid_variant_and_is_empty_for_schema_compilation() {
409 let mut instance = minimal_valid_document();
410 instance.as_object_mut().unwrap().remove("conceptType");
411 let error = validate_document(&instance).unwrap_err();
412 assert!(matches!(error, MifSchemaError::Invalid(_)));
413 assert!(!error.messages().is_empty());
414
415 let compilation_error = MifSchemaError::SchemaCompilation(SchemaCompilationSource(
416 "synthetic failure for coverage".to_string(),
417 ));
418 assert!(compilation_error.messages().is_empty());
419 }
420
421 #[test]
422 fn document_with_bad_id_pattern_fails() {
423 let mut instance = minimal_valid_document();
424 instance["@id"] = json!("not-a-urn");
425 assert!(validate_document(&instance).is_err());
426 }
427
428 #[test]
429 fn document_with_entity_reference_resolves_ref_chain() {
430 let mut instance = minimal_valid_document();
431 instance["entities"] = json!([{
432 "@type": "EntityReference",
433 "entity": { "@id": "urn:mif:entity:person:jane-smith" },
434 "entityType": "Person",
435 }]);
436 assert!(validate_document(&instance).is_ok());
437 }
438
439 #[test]
440 fn document_with_invalid_entity_reference_fails() {
441 let mut instance = minimal_valid_document();
442 instance["entities"] = json!([{
443 "@type": "EntityReference",
444 "entity": { "@id": "not-a-urn" },
445 }]);
446 assert!(validate_document(&instance).is_err());
447 }
448
449 #[test]
450 fn valid_citation_passes() {
451 let citation = json!({
452 "@type": "Citation",
453 "citationType": "documentation",
454 "citationRole": "source",
455 "title": "MIF Specification",
456 "url": "https://mif-spec.dev",
457 });
458 assert!(validate_citation(&citation).is_ok());
459 }
460
461 #[test]
462 fn valid_ontology_definition_passes() {
463 let ontology = json!({
464 "ontology": {
465 "id": "mif-base",
466 "version": "1.0.0",
467 }
468 });
469 assert!(validate_ontology_definition(&ontology).is_ok());
470 }
471
472 #[test]
473 fn ontology_definition_with_bad_id_pattern_fails() {
474 let ontology = json!({
475 "ontology": {
476 "id": "Not_Valid",
477 "version": "1.0.0",
478 }
479 });
480 assert!(validate_ontology_definition(&ontology).is_err());
481 }
482
483 #[test]
484 fn entity_type_with_classification_fields_passes() {
485 let ontology = json!({
487 "ontology": {
488 "id": "sec-fixture",
489 "version": "1.1.0",
490 },
491 "entity_types": [{
492 "name": "control",
493 "base": "semantic",
494 "aliases": ["safeguard", "countermeasure"],
495 "exemplars": ["Enforce MFA for all administrative access"],
496 "negative_examples": ["An incident report describing a control failure"],
497 }]
498 });
499 assert!(validate_ontology_definition(&ontology).is_ok());
500 }
501
502 #[test]
503 fn entity_type_classification_fields_reject_non_string_and_empty_items() {
504 let non_string_alias = json!({
505 "ontology": { "id": "sec-fixture", "version": "1.1.0" },
506 "entity_types": [{
507 "name": "control",
508 "base": "semantic",
509 "aliases": [123],
510 }]
511 });
512 assert!(validate_ontology_definition(&non_string_alias).is_err());
513
514 let empty_exemplar = json!({
515 "ontology": { "id": "sec-fixture", "version": "1.1.0" },
516 "entity_types": [{
517 "name": "control",
518 "base": "semantic",
519 "exemplars": [""],
520 }]
521 });
522 assert!(validate_ontology_definition(&empty_exemplar).is_err());
523 }
524
525 #[test]
526 fn citation_missing_required_field_fails() {
527 let citation = json!({
528 "@type": "Citation",
529 "citationType": "documentation",
530 "citationRole": "source",
531 "title": "MIF Specification",
532 });
533 assert!(validate_citation(&citation).is_err());
534 }
535
536 #[test]
537 fn invalid_document_maps_to_versioned_problem_details() {
538 let mut instance = minimal_valid_document();
539 instance.as_object_mut().unwrap().remove("conceptType");
540 let error = validate_document(&instance).unwrap_err();
541 let problem = error.to_problem();
542
543 assert_eq!(
544 problem.problem_type,
545 "https://modeled-information-format.github.io/mif-rs/references/errors/invalid-document/v1"
546 );
547 assert_eq!(problem.status, 422);
548 assert_eq!(problem.exit_code, Some(2));
549 assert!(problem.suggested_fix.is_some());
550 assert_eq!(problem.code_actions.len(), 1);
551 assert!(problem.detail.contains("schema validation"));
552 }
553
554 #[test]
555 fn schema_compilation_error_maps_to_distinct_problem_type() {
556 let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
557 let problem = error.to_problem();
558
559 assert_eq!(
560 problem.problem_type,
561 "https://modeled-information-format.github.io/mif-rs/references/errors/schema-compilation/v1"
562 );
563 assert_eq!(problem.status, 500);
564 assert_eq!(problem.exit_code, Some(1));
565 }
566
567 #[test]
568 fn schema_compilation_error_source_preserves_the_underlying_cause() {
569 let error = MifSchemaError::SchemaCompilation(SchemaCompilationSource("boom".to_string()));
570
571 let source = std::error::Error::source(&error).expect("source should not be None");
572 assert_eq!(source.to_string(), "boom");
573 }
574
575 fn l2_document() -> serde_json::Value {
576 let mut instance = minimal_valid_document();
577 instance["namespace"] = json!("test");
578 instance["modified"] = json!("2026-07-02T00:00:00Z");
579 instance["temporal"] = json!({});
580 instance
581 }
582
583 fn l3_document() -> serde_json::Value {
584 let mut instance = l2_document();
585 instance["provenance"] = json!({});
586 instance["temporal"]["validFrom"] = json!("2026-07-02T00:00:00Z");
587 instance
588 }
589
590 #[test]
591 fn level_display_formats_as_l_and_number() {
592 assert_eq!(Level::L1.to_string(), "L1");
593 assert_eq!(Level::L2.to_string(), "L2");
594 assert_eq!(Level::L3.to_string(), "L3");
595 }
596
597 #[test]
598 fn level_try_from_accepts_one_two_three() {
599 assert_eq!(Level::try_from(1).unwrap(), Level::L1);
600 assert_eq!(Level::try_from(2).unwrap(), Level::L2);
601 assert_eq!(Level::try_from(3).unwrap(), Level::L3);
602 }
603
604 #[test]
605 fn level_try_from_rejects_out_of_range_numbers() {
606 let error = Level::try_from(0).unwrap_err();
607 assert!(matches!(error, MifSchemaError::UnsupportedLevel(0)));
608 assert!(error.messages().is_empty());
609
610 let error = Level::try_from(4).unwrap_err();
611 assert!(matches!(error, MifSchemaError::UnsupportedLevel(4)));
612 }
613
614 #[test]
615 fn unsupported_level_error_maps_to_versioned_problem_details() {
616 let error = MifSchemaError::UnsupportedLevel(9);
617 let problem = error.to_problem();
618
619 assert_eq!(
620 problem.problem_type,
621 "https://modeled-information-format.github.io/mif-rs/references/errors/unsupported-level/v1"
622 );
623 assert_eq!(problem.status, 400);
624 assert_eq!(problem.exit_code, Some(2));
625 }
626
627 #[test]
628 fn validate_level_l1_is_satisfied_by_the_minimal_valid_document() {
629 assert!(validate_level(&minimal_valid_document(), Level::L1).is_ok());
630 }
631
632 #[test]
633 fn validate_level_l2_fails_on_a_document_missing_namespace_modified_temporal() {
634 let error = validate_level(&minimal_valid_document(), Level::L2).unwrap_err();
635 let MifSchemaError::LevelFloorViolation { level, missing } = error else {
636 unreachable!("expected LevelFloorViolation")
637 };
638 assert_eq!(level, Level::L2);
639 assert_eq!(missing, vec!["namespace", "modified", "temporal"]);
640 }
641
642 #[test]
643 fn validate_level_l2_passes_once_namespace_modified_temporal_are_present() {
644 assert!(validate_level(&l2_document(), Level::L2).is_ok());
645 }
646
647 #[test]
648 fn validate_level_l3_fails_on_a_document_missing_provenance_and_valid_from() {
649 let error = validate_level(&l2_document(), Level::L3).unwrap_err();
650 let MifSchemaError::LevelFloorViolation { level, missing } = error else {
651 unreachable!("expected LevelFloorViolation")
652 };
653 assert_eq!(level, Level::L3);
654 assert_eq!(missing, vec!["provenance", "temporal.validFrom"]);
655 }
656
657 #[test]
658 fn validate_level_l3_treats_a_null_valid_from_as_missing() {
659 let mut instance = l2_document();
660 instance["provenance"] = json!({});
661 instance["temporal"]["validFrom"] = serde_json::Value::Null;
662 let error = validate_level(&instance, Level::L3).unwrap_err();
663 let MifSchemaError::LevelFloorViolation { missing, .. } = error else {
664 unreachable!("expected LevelFloorViolation")
665 };
666 assert_eq!(missing, vec!["temporal.validFrom"]);
667 }
668
669 #[test]
670 fn validate_level_l3_passes_once_provenance_and_valid_from_are_present() {
671 assert!(validate_level(&l3_document(), Level::L3).is_ok());
672 }
673
674 #[test]
675 fn validate_level_reports_core_schema_failures_before_level_floor_checks() {
676 let mut instance = minimal_valid_document();
677 instance.as_object_mut().unwrap().remove("conceptType");
678 let error = validate_level(&instance, Level::L3).unwrap_err();
679 assert!(matches!(error, MifSchemaError::Invalid(_)));
680 }
681
682 #[test]
683 fn level_floor_violation_maps_to_versioned_problem_details() {
684 let error = validate_level(&minimal_valid_document(), Level::L2).unwrap_err();
685 let problem = error.to_problem();
686
687 assert_eq!(
688 problem.problem_type,
689 "https://modeled-information-format.github.io/mif-rs/references/errors/level-floor-violation/v1"
690 );
691 assert_eq!(problem.status, 422);
692 assert_eq!(problem.exit_code, Some(5));
693 assert!(problem.suggested_fix.is_some());
694 assert!(!error.messages().is_empty());
695 }
696}