Skip to main content

rh_foundation/
validation.rs

1//! FHIR validation types
2//!
3//! Shared types for FHIR validation that are used across multiple crates.
4
5use serde::{Deserialize, Serialize};
6
7/// Severity level of a validation issue
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11    /// Fatal error - validation failed
12    Error,
13    /// Warning - best practice violation
14    Warning,
15    /// Informational message
16    Information,
17}
18
19impl Severity {
20    fn rank(&self) -> u8 {
21        match self {
22            Severity::Error => 2,
23            Severity::Warning => 1,
24            Severity::Information => 0,
25        }
26    }
27}
28
29impl PartialOrd for Severity {
30    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
31        Some(self.cmp(other))
32    }
33}
34
35impl Ord for Severity {
36    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
37        self.rank().cmp(&other.rank())
38    }
39}
40
41impl std::fmt::Display for Severity {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Severity::Error => write!(f, "error"),
45            Severity::Warning => write!(f, "warning"),
46            Severity::Information => write!(f, "information"),
47        }
48    }
49}
50
51/// Binding strength for ValueSet bindings
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum BindingStrength {
55    /// Value MUST come from the ValueSet
56    Required,
57    /// Value SHOULD come from the ValueSet (can use other codes if needed)
58    Extensible,
59    /// Value is recommended to come from the ValueSet
60    Preferred,
61    /// ValueSet is for illustration only
62    Example,
63}
64
65impl BindingStrength {
66    /// Parse from FHIR string representation
67    pub fn from_fhir_str(s: &str) -> Option<Self> {
68        match s {
69            "required" => Some(BindingStrength::Required),
70            "extensible" => Some(BindingStrength::Extensible),
71            "preferred" => Some(BindingStrength::Preferred),
72            "example" => Some(BindingStrength::Example),
73            _ => None,
74        }
75    }
76}
77
78impl std::fmt::Display for BindingStrength {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            BindingStrength::Required => write!(f, "required"),
82            BindingStrength::Extensible => write!(f, "extensible"),
83            BindingStrength::Preferred => write!(f, "preferred"),
84            BindingStrength::Example => write!(f, "example"),
85        }
86    }
87}
88
89/// Element binding to a ValueSet
90///
91/// This structure is shared between the validator and code generator.
92/// Generated resources embed these as static constants for elements with required bindings.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct ElementBinding {
95    /// Element path (e.g., "Patient.gender")
96    pub path: String,
97    /// Binding strength
98    pub strength: BindingStrength,
99    /// ValueSet canonical URL
100    pub value_set_url: String,
101    /// Human-readable description
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub description: Option<String>,
104}
105
106impl ElementBinding {
107    /// Create a new element binding
108    pub fn new(
109        path: impl Into<String>,
110        strength: BindingStrength,
111        value_set_url: impl Into<String>,
112    ) -> Self {
113        Self {
114            path: path.into(),
115            strength,
116            value_set_url: value_set_url.into(),
117            description: None,
118        }
119    }
120
121    /// Add description
122    pub fn with_description(mut self, description: impl Into<String>) -> Self {
123        self.description = Some(description.into());
124        self
125    }
126}
127
128/// Invariant constraint from FHIR specification
129///
130/// This structure is shared between the validator and code generator.
131/// Generated resources embed these as static constants.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct Invariant {
134    /// Invariant key (e.g., "pat-1", "obs-3")
135    pub key: String,
136    /// Severity level
137    pub severity: Severity,
138    /// Human-readable description
139    pub human: String,
140    /// FHIRPath expression to evaluate
141    pub expression: String,
142    /// XPath expression (legacy, optional)
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub xpath: Option<String>,
145}
146
147impl Invariant {
148    /// Create a new invariant
149    pub fn new(
150        key: impl Into<String>,
151        severity: Severity,
152        human: impl Into<String>,
153        expression: impl Into<String>,
154    ) -> Self {
155        Self {
156            key: key.into(),
157            severity,
158            human: human.into(),
159            expression: expression.into(),
160            xpath: None,
161        }
162    }
163
164    /// Add XPath expression (legacy support)
165    pub fn with_xpath(mut self, xpath: impl Into<String>) -> Self {
166        self.xpath = Some(xpath.into());
167        self
168    }
169}
170
171/// Element cardinality constraint
172///
173/// Defines the minimum and maximum occurrences allowed for a FHIR element.
174/// This structure is shared between the validator and code generator.
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
176pub struct ElementCardinality {
177    /// Element path (e.g., "Patient.identifier", "Patient.name")
178    pub path: String,
179    /// Minimum occurrences (0 = optional, 1+ = required)
180    pub min: usize,
181    /// Maximum occurrences (None = unbounded (*), Some(n) = up to n)
182    pub max: Option<usize>,
183}
184
185impl ElementCardinality {
186    /// Create a new cardinality constraint
187    pub fn new(path: impl Into<String>, min: usize, max: Option<usize>) -> Self {
188        Self {
189            path: path.into(),
190            min,
191            max,
192        }
193    }
194
195    /// Check if the cardinality allows unbounded occurrences
196    pub fn is_unbounded(&self) -> bool {
197        self.max.is_none()
198    }
199
200    /// Check if the element is required (min > 0)
201    pub fn is_required(&self) -> bool {
202        self.min > 0
203    }
204
205    /// Check if the element can be an array (max > 1 or unbounded)
206    pub fn is_array(&self) -> bool {
207        self.max.is_none_or(|m| m > 1)
208    }
209
210    /// Format cardinality as FHIR notation (e.g., "0..1", "1..*", "0..5")
211    pub fn to_fhir_notation(&self) -> String {
212        match self.max {
213            None => format!("{}..*", self.min),
214            Some(max) => format!("{}..{}", self.min, max),
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_severity_ordering() {
225        assert!(Severity::Error > Severity::Warning);
226        assert!(Severity::Warning > Severity::Information);
227    }
228
229    #[test]
230    fn test_invariant_creation() {
231        let inv = Invariant::new(
232            "pat-1",
233            Severity::Error,
234            "Name is required",
235            "name.exists()",
236        );
237        assert_eq!(inv.key, "pat-1");
238        assert_eq!(inv.severity, Severity::Error);
239        assert_eq!(inv.human, "Name is required");
240        assert_eq!(inv.expression, "name.exists()");
241        assert!(inv.xpath.is_none());
242    }
243
244    #[test]
245    fn test_invariant_with_xpath() {
246        let inv = Invariant::new("obs-1", Severity::Warning, "Code required", "code.exists()")
247            .with_xpath("f:code");
248        assert!(inv.xpath.is_some());
249    }
250
251    #[test]
252    fn test_element_cardinality_creation() {
253        let card = ElementCardinality::new("Patient.identifier", 0, None);
254        assert_eq!(card.path, "Patient.identifier");
255        assert_eq!(card.min, 0);
256        assert!(card.max.is_none());
257        assert!(card.is_unbounded());
258        assert!(!card.is_required());
259        assert!(card.is_array());
260    }
261
262    #[test]
263    fn test_element_cardinality_required_single() {
264        let card = ElementCardinality::new("Observation.code", 1, Some(1));
265        assert!(card.is_required());
266        assert!(!card.is_unbounded());
267        assert!(!card.is_array());
268        assert_eq!(card.to_fhir_notation(), "1..1");
269    }
270
271    #[test]
272    fn test_element_cardinality_optional_array() {
273        let card = ElementCardinality::new("Patient.name", 0, None);
274        assert!(!card.is_required());
275        assert!(card.is_unbounded());
276        assert!(card.is_array());
277        assert_eq!(card.to_fhir_notation(), "0..*");
278    }
279
280    #[test]
281    fn test_element_cardinality_bounded_array() {
282        let card = ElementCardinality::new("Patient.photo", 0, Some(5));
283        assert!(!card.is_required());
284        assert!(!card.is_unbounded());
285        assert!(card.is_array());
286        assert_eq!(card.to_fhir_notation(), "0..5");
287    }
288
289    #[test]
290    fn test_invariant_with_xpath_value() {
291        let inv = Invariant::new("obs-1", Severity::Warning, "Code required", "code.exists()")
292            .with_xpath("f:code");
293        assert_eq!(inv.xpath.unwrap(), "f:code");
294    }
295
296    #[test]
297    fn test_invariant_serialization() {
298        let inv = Invariant::new("test-1", Severity::Error, "Test", "true");
299        let json = serde_json::to_string(&inv).unwrap();
300        assert!(json.contains("test-1"));
301        assert!(json.contains("error"));
302    }
303
304    #[test]
305    fn test_binding_strength_parsing() {
306        assert_eq!(
307            BindingStrength::from_fhir_str("required"),
308            Some(BindingStrength::Required)
309        );
310        assert_eq!(
311            BindingStrength::from_fhir_str("extensible"),
312            Some(BindingStrength::Extensible)
313        );
314        assert_eq!(
315            BindingStrength::from_fhir_str("preferred"),
316            Some(BindingStrength::Preferred)
317        );
318        assert_eq!(
319            BindingStrength::from_fhir_str("example"),
320            Some(BindingStrength::Example)
321        );
322        assert_eq!(BindingStrength::from_fhir_str("invalid"), None);
323    }
324
325    #[test]
326    fn test_element_binding_creation() {
327        let binding = ElementBinding::new(
328            "Patient.gender",
329            BindingStrength::Required,
330            "http://hl7.org/fhir/ValueSet/administrative-gender",
331        );
332        assert_eq!(binding.path, "Patient.gender");
333        assert_eq!(binding.strength, BindingStrength::Required);
334        assert_eq!(
335            binding.value_set_url,
336            "http://hl7.org/fhir/ValueSet/administrative-gender"
337        );
338        assert!(binding.description.is_none());
339    }
340
341    #[test]
342    fn test_element_binding_with_description() {
343        let binding = ElementBinding::new(
344            "Patient.gender",
345            BindingStrength::Required,
346            "http://hl7.org/fhir/ValueSet/administrative-gender",
347        )
348        .with_description("The gender of the patient");
349        assert!(binding.description.is_some());
350        assert_eq!(binding.description.unwrap(), "The gender of the patient");
351    }
352}