tlq_fhir_validator/
lib.rs

1//! FHIR Validator - Three-phase, pipeline-based validation architecture
2//!
3//! # Architecture
4//!
5//! The validator separates configuration, planning, and execution:
6//!
7//! ```text
8//! ValidatorConfig (declarative) → ValidationPlan (executable) → Validator (reusable)
9//! ```
10//!
11//! ## Phase 1: Declarative Configuration
12//!
13//! Define validation behavior via [`ValidatorConfig`]:
14//! - What aspects to validate (schema, profiles, invariants, terminology, etc.)
15//! - Strictness levels and policies
16//! - Preset-based or fully custom
17//! - Serializable (YAML/JSON)
18//!
19//! ## Phase 2: Compiled Validation Plan
20//!
21//! Configuration compiles into a [`ValidationPlan`]:
22//! - Ordered list of stateless validation steps
23//! - Validates configuration correctness
24//! - Eliminates unused features
25//! - Minimal, executable pipeline
26//!
27//! ## Phase 3: Reusable Validator & Stateless Execution
28//!
29//! [`Validator<C>`] owns the plan and FHIR context:
30//! - Generic over context type (e.g., `DefaultFhirContext`)
31//! - Reusable across many validations
32//! - Each `validate()` call creates a short-lived `ValidationRun`
33//! - Returns structured [`ValidationOutcome`]
34//!
35//! # Key Properties
36//!
37//! - **No combinatorial explosion**: Capabilities selected via configuration, not chained APIs
38//! - **FHIR-context driven**: All profile/terminology resolution delegated to `fhir-context`
39//! - **Extensible**: New validation steps added without breaking public API
40//! - **Reusable & performant**: Heavy initialization once, cheap execution
41
42use std::time::Duration;
43use serde::{Deserialize, Serialize};
44
45mod plan;
46mod error;
47mod validator;
48mod steps;
49
50pub use plan::{ValidationPlan, Step, SchemaPlan, ProfilesPlan, ConstraintsPlan, TerminologyPlan, ReferencesPlan, BundlePlan};
51pub use error::ConfigError;
52pub use validator::{Validator, ValidationOutcome, ValidationIssue, IssueSeverity, IssueCode};
53
54// ============================================================================
55// Core Config
56// ============================================================================
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ValidatorConfig {
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub preset: Option<Preset>,
62    #[serde(default)]
63    pub fhir: FhirConfig,
64    #[serde(default)]
65    pub report: ReportConfig,
66    #[serde(default)]
67    pub exec: ExecConfig,
68    #[serde(default)]
69    pub schema: SchemaConfig,
70    #[serde(default)]
71    pub constraints: ConstraintsConfig,
72    #[serde(default)]
73    pub profiles: ProfilesConfig,
74    #[serde(default)]
75    pub terminology: TerminologyConfig,
76    #[serde(default)]
77    pub references: ReferencesConfig,
78    #[serde(default)]
79    pub bundles: BundleConfig,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83pub enum Preset {
84    Ingestion,
85    Authoring,
86    Server,
87    Publication,
88}
89
90// ============================================================================
91// FHIR Config
92// ============================================================================
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct FhirConfig {
96    #[serde(default = "default_fhir_version")]
97    pub version: FhirVersion,
98    #[serde(default)]
99    pub allow_version_mismatch: bool,
100}
101
102fn default_fhir_version() -> FhirVersion {
103    FhirVersion::R5
104}
105
106impl Default for FhirConfig {
107    fn default() -> Self {
108        Self {
109            version: FhirVersion::R5,
110            allow_version_mismatch: false,
111        }
112    }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum FhirVersion {
117    R4,
118    R5,
119}
120
121// ============================================================================
122// Execution Config
123// ============================================================================
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ExecConfig {
127    #[serde(default)]
128    pub fail_fast: bool,
129    #[serde(default = "default_max_issues")]
130    pub max_issues: usize,
131}
132
133fn default_max_issues() -> usize {
134    1000
135}
136
137impl Default for ExecConfig {
138    fn default() -> Self {
139        Self {
140            fail_fast: false,
141            max_issues: 1000,
142        }
143    }
144}
145
146// ============================================================================
147// Report Config
148// ============================================================================
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ReportConfig {
152    #[serde(default = "default_include_warnings")]
153    pub include_warnings: bool,
154    #[serde(default)]
155    pub include_information: bool,
156}
157
158fn default_include_warnings() -> bool {
159    true
160}
161
162impl Default for ReportConfig {
163    fn default() -> Self {
164        Self {
165            include_warnings: true,
166            include_information: false,
167        }
168    }
169}
170
171// ============================================================================
172// Schema Config
173// ============================================================================
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SchemaConfig {
177    #[serde(default = "default_schema_mode")]
178    pub mode: SchemaMode,
179    #[serde(default)]
180    pub allow_unknown_elements: bool,
181    #[serde(default)]
182    pub allow_modifier_extensions: bool,
183}
184
185fn default_schema_mode() -> SchemaMode {
186    SchemaMode::On
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190pub enum SchemaMode {
191    Off,
192    On,
193}
194
195impl Default for SchemaConfig {
196    fn default() -> Self {
197        Self {
198            mode: SchemaMode::On,
199            allow_unknown_elements: false,
200            allow_modifier_extensions: false,
201        }
202    }
203}
204
205// ============================================================================
206// Constraints Config
207// ============================================================================
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct ConstraintsConfig {
211    #[serde(default)]
212    pub mode: ConstraintsMode,
213    #[serde(default)]
214    pub best_practice: BestPracticeMode,
215    #[serde(default)]
216    pub suppress: Vec<ConstraintId>,
217    #[serde(default)]
218    pub level_overrides: Vec<ConstraintLevelOverride>,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222#[derive(Default)]
223pub enum ConstraintsMode {
224    Off,
225    InvariantsOnly,
226    #[default]
227    Full,
228}
229
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[derive(Default)]
233pub enum BestPracticeMode {
234    #[default]
235    Ignore,
236    Warn,
237    Error,
238}
239
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242pub struct ConstraintId(pub String);
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct ConstraintLevelOverride {
246    pub id: ConstraintId,
247    pub level: IssueLevel,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251pub enum IssueLevel {
252    Error,
253    Warning,
254    Information,
255}
256
257impl Default for ConstraintsConfig {
258    fn default() -> Self {
259        Self {
260            mode: ConstraintsMode::Full,
261            best_practice: BestPracticeMode::Ignore,
262            suppress: vec![],
263            level_overrides: vec![],
264        }
265    }
266}
267
268// ============================================================================
269// Profiles Config
270// ============================================================================
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ProfilesConfig {
274    #[serde(default)]
275    pub mode: ProfilesMode,
276    /// Explicit list of profile URLs to validate against.
277    /// If provided, validates against these profiles instead of (or in addition to) meta.profile.
278    /// If not provided, validates against profiles declared in resource.meta.profile.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub explicit_profiles: Option<Vec<String>>,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[derive(Default)]
285pub enum ProfilesMode {
286    #[default]
287    Off,
288    On,
289}
290
291
292impl Default for ProfilesConfig {
293    fn default() -> Self {
294        Self {
295            mode: ProfilesMode::Off,
296            explicit_profiles: None,
297        }
298    }
299}
300
301// ============================================================================
302// Terminology Config
303// ============================================================================
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct TerminologyConfig {
307    #[serde(default)]
308    pub mode: TerminologyMode,
309    #[serde(default)]
310    pub extensible_handling: ExtensibleHandling,
311    #[serde(with = "duration_millis", default = "default_terminology_timeout")]
312    pub timeout: Duration,
313    #[serde(default)]
314    pub on_timeout: TimeoutPolicy,
315    #[serde(default)]
316    pub cache: CachePolicy,
317}
318
319fn default_terminology_timeout() -> Duration {
320    Duration::from_millis(1500)
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[derive(Default)]
325pub enum TerminologyMode {
326    #[default]
327    Off,
328    Local,
329    Remote,
330    Hybrid,
331}
332
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[derive(Default)]
336pub enum ExtensibleHandling {
337    Ignore,
338    #[default]
339    Warn,
340    Error,
341}
342
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[derive(Default)]
346pub enum TimeoutPolicy {
347    Skip,
348    #[default]
349    Warn,
350    Error,
351}
352
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[derive(Default)]
356pub enum CachePolicy {
357    None,
358    #[default]
359    Memory,
360}
361
362
363impl Default for TerminologyConfig {
364    fn default() -> Self {
365        Self {
366            mode: TerminologyMode::Off,
367            extensible_handling: ExtensibleHandling::Warn,
368            timeout: Duration::from_millis(1500),
369            on_timeout: TimeoutPolicy::Warn,
370            cache: CachePolicy::Memory,
371        }
372    }
373}
374
375// Duration serialization helper
376mod duration_millis {
377    use std::time::Duration;
378    use serde::{Deserialize, Deserializer, Serializer};
379
380    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
381    where
382        S: Serializer,
383    {
384        serializer.serialize_u64(duration.as_millis() as u64)
385    }
386
387    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
388    where
389        D: Deserializer<'de>,
390    {
391        let millis = u64::deserialize(deserializer)?;
392        Ok(Duration::from_millis(millis))
393    }
394}
395
396// ============================================================================
397// References Config
398// ============================================================================
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct ReferencesConfig {
402    #[serde(default)]
403    pub mode: ReferenceMode,
404    #[serde(default = "default_allow_external")]
405    pub allow_external: bool,
406}
407
408fn default_allow_external() -> bool {
409    true
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413#[derive(Default)]
414pub enum ReferenceMode {
415    #[default]
416    Off,
417    TypeOnly,
418    Existence,
419    Full,
420}
421
422
423impl Default for ReferencesConfig {
424    fn default() -> Self {
425        Self {
426            mode: ReferenceMode::Off,
427            allow_external: true,
428        }
429    }
430}
431
432// ============================================================================
433// Bundle Config
434// ============================================================================
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct BundleConfig {
438    #[serde(default)]
439    pub mode: BundleMode,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[derive(Default)]
444pub enum BundleMode {
445    #[default]
446    Off,
447    On,
448}
449
450
451impl Default for BundleConfig {
452    fn default() -> Self {
453        Self {
454            mode: BundleMode::Off,
455        }
456    }
457}
458
459// ============================================================================
460// ValidatorConfig Implementation
461// ============================================================================
462
463impl ValidatorConfig {
464    pub fn preset(p: Preset) -> Self {
465        let mut cfg = Self::defaults();
466        cfg.preset = Some(p);
467
468        match p {
469            Preset::Ingestion => {
470                cfg.schema.mode = SchemaMode::On;
471                cfg.constraints.mode = ConstraintsMode::Off;
472                cfg.profiles.mode = ProfilesMode::Off;
473                cfg.terminology.mode = TerminologyMode::Off;
474                cfg.references.mode = ReferenceMode::Off;
475            }
476            Preset::Authoring => {
477                cfg.schema.mode = SchemaMode::On;
478                cfg.profiles.mode = ProfilesMode::On;
479                cfg.constraints.mode = ConstraintsMode::Full;
480                cfg.terminology.mode = TerminologyMode::Local;
481            }
482            Preset::Server => {
483                cfg.schema.mode = SchemaMode::On;
484                cfg.profiles.mode = ProfilesMode::On;
485                cfg.constraints.mode = ConstraintsMode::Full;
486                cfg.terminology.mode = TerminologyMode::Hybrid;
487                cfg.references.mode = ReferenceMode::Existence;
488            }
489            Preset::Publication => {
490                cfg.schema.mode = SchemaMode::On;
491                cfg.profiles.mode = ProfilesMode::On;
492                cfg.constraints.mode = ConstraintsMode::Full;
493                cfg.constraints.best_practice = BestPracticeMode::Warn;
494                cfg.terminology.mode = TerminologyMode::Remote;
495                cfg.references.mode = ReferenceMode::Full;
496            }
497        }
498
499        cfg
500    }
501
502    pub fn defaults() -> Self {
503        Self {
504            preset: None,
505            fhir: FhirConfig {
506                version: FhirVersion::R5,
507                allow_version_mismatch: false,
508            },
509            report: ReportConfig::default(),
510            exec: ExecConfig::default(),
511            schema: SchemaConfig::default(),
512            constraints: ConstraintsConfig::default(),
513            profiles: ProfilesConfig::default(),
514            terminology: TerminologyConfig::default(),
515            references: ReferencesConfig::default(),
516            bundles: BundleConfig::default(),
517        }
518    }
519
520    pub fn compile(&self) -> Result<ValidationPlan, ConfigError> {
521        // Validate incompatible combinations
522        if self.references.mode == ReferenceMode::Full
523            && self.terminology.mode == TerminologyMode::Off
524        {
525            return Err(ConfigError::TerminologyRequiredForFullRef);
526        }
527
528        let mut steps = Vec::new();
529
530        if self.schema.mode == SchemaMode::On {
531            steps.push(Step::Schema(SchemaPlan::from(&self.schema)));
532        }
533        if self.profiles.mode == ProfilesMode::On {
534            steps.push(Step::Profiles(ProfilesPlan::from(&self.profiles)));
535        }
536        if self.constraints.mode != ConstraintsMode::Off {
537            steps.push(Step::Constraints(ConstraintsPlan::from(&self.constraints)));
538        }
539        if self.terminology.mode != TerminologyMode::Off {
540            steps.push(Step::Terminology(TerminologyPlan::from(
541                &self.terminology,
542            )));
543        }
544        if self.references.mode != ReferenceMode::Off {
545            steps.push(Step::References(ReferencesPlan::from(&self.references)));
546        }
547        if self.bundles.mode == BundleMode::On {
548            steps.push(Step::Bundles(BundlePlan::from(&self.bundles)));
549        }
550
551        Ok(ValidationPlan {
552            steps,
553            fail_fast: self.exec.fail_fast,
554            max_issues: self.exec.max_issues,
555        })
556    }
557
558    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
559        serde_yaml::from_str(yaml)
560    }
561
562    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
563        serde_yaml::to_string(self)
564    }
565
566    pub fn builder() -> ValidatorConfigBuilder {
567        ValidatorConfigBuilder::default()
568    }
569}
570
571impl Default for ValidatorConfig {
572    fn default() -> Self {
573        Self::defaults()
574    }
575}
576
577// ============================================================================
578// Builder Pattern
579// ============================================================================
580
581#[derive(Debug, Default, Clone)]
582pub struct ValidatorConfigBuilder {
583    cfg: Option<ValidatorConfig>,
584}
585
586impl ValidatorConfigBuilder {
587    pub fn preset(mut self, p: Preset) -> Self {
588        self.cfg = Some(ValidatorConfig::preset(p));
589        self
590    }
591
592    pub fn fhir_version(mut self, version: FhirVersion) -> Self {
593        self.cfg().fhir.version = version;
594        self
595    }
596
597    pub fn schema_mode(mut self, mode: SchemaMode) -> Self {
598        self.cfg().schema.mode = mode;
599        self
600    }
601
602    pub fn constraints_mode(mut self, mode: ConstraintsMode) -> Self {
603        self.cfg().constraints.mode = mode;
604        self
605    }
606
607    pub fn profiles_mode(mut self, mode: ProfilesMode) -> Self {
608        self.cfg().profiles.mode = mode;
609        self
610    }
611
612    pub fn terminology_mode(mut self, mode: TerminologyMode) -> Self {
613        self.cfg().terminology.mode = mode;
614        self
615    }
616
617    pub fn reference_mode(mut self, mode: ReferenceMode) -> Self {
618        self.cfg().references.mode = mode;
619        self
620    }
621
622    pub fn fail_fast(mut self, fail_fast: bool) -> Self {
623        self.cfg().exec.fail_fast = fail_fast;
624        self
625    }
626
627    pub fn max_issues(mut self, max: usize) -> Self {
628        self.cfg().exec.max_issues = max;
629        self
630    }
631
632    pub fn build(self) -> ValidatorConfig {
633        self.cfg.unwrap_or_default()
634    }
635
636    fn cfg(&mut self) -> &mut ValidatorConfig {
637        self.cfg.get_or_insert_with(ValidatorConfig::defaults)
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn test_preset_ingestion() {
647        let cfg = ValidatorConfig::preset(Preset::Ingestion);
648        assert_eq!(cfg.schema.mode, SchemaMode::On);
649        assert_eq!(cfg.constraints.mode, ConstraintsMode::Off);
650        assert_eq!(cfg.terminology.mode, TerminologyMode::Off);
651    }
652
653    #[test]
654    fn test_builder() {
655        let cfg = ValidatorConfig::builder()
656            .preset(Preset::Server)
657            .terminology_mode(TerminologyMode::Local)
658            .fail_fast(true)
659            .build();
660
661        assert_eq!(cfg.preset, Some(Preset::Server));
662        assert_eq!(cfg.terminology.mode, TerminologyMode::Local);
663        assert!(cfg.exec.fail_fast);
664    }
665
666    #[test]
667    fn test_yaml_roundtrip() {
668        let cfg = ValidatorConfig::preset(Preset::Authoring);
669        let yaml = cfg.to_yaml().unwrap();
670        let parsed = ValidatorConfig::from_yaml(&yaml).unwrap();
671        assert_eq!(cfg.preset, parsed.preset);
672        assert_eq!(cfg.schema.mode, parsed.schema.mode);
673    }
674
675    #[test]
676    fn test_compile_validation() {
677        let cfg = ValidatorConfig::builder()
678            .reference_mode(ReferenceMode::Full)
679            .terminology_mode(TerminologyMode::Off)
680            .build();
681
682        let result = cfg.compile();
683        assert!(matches!(result, Err(ConfigError::TerminologyRequiredForFullRef)));
684    }
685}