Skip to main content

wsi_dicom/
metadata.rs

1use std::io::Read;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::Error;
7
8/// Maximum accepted metadata JSON file size.
9pub const METADATA_JSON_MAX_BYTES: u64 = 16 * 1024 * 1024;
10
11/// Metadata accepted by the DICOM writer after strict JSON or FHIR mapping.
12#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct DicomMetadata {
15    /// DICOM Patient Name.
16    pub patient_name: Option<String>,
17    /// DICOM Patient ID.
18    pub patient_id: Option<String>,
19    /// DICOM Patient Birth Date in DA format.
20    pub patient_birth_date: Option<String>,
21    /// DICOM Patient Sex.
22    pub patient_sex: Option<String>,
23    /// DICOM Accession Number.
24    pub accession_number: Option<String>,
25    /// Optional caller-supplied Study Instance UID.
26    pub study_instance_uid: Option<String>,
27    /// DICOM Study ID.
28    pub study_id: Option<String>,
29    /// DICOM Study Date in DA format.
30    pub study_date: Option<String>,
31    /// DICOM Study Time in TM format.
32    pub study_time: Option<String>,
33    /// DICOM Study Description.
34    pub study_description: Option<String>,
35    /// DICOM Referring Physician Name.
36    pub referring_physician_name: Option<String>,
37    /// DICOM Laterality.
38    pub laterality: Option<String>,
39    /// Equipment manufacturer.
40    pub manufacturer: Option<String>,
41    /// Equipment model name.
42    pub manufacturer_model_name: Option<String>,
43    /// Equipment serial number.
44    pub device_serial_number: Option<String>,
45    /// Software version string recorded in generated instances.
46    pub software_versions: Option<String>,
47    /// DICOM Content Date in DA format.
48    pub content_date: Option<String>,
49    /// DICOM Content Time in TM format.
50    pub content_time: Option<String>,
51    /// DICOM Acquisition DateTime in DT format.
52    pub acquisition_date_time: Option<String>,
53    /// Container identifier for the specimen container.
54    pub container_identifier: Option<String>,
55    /// Specimen identifier.
56    pub specimen_identifier: Option<String>,
57    /// Human-readable specimen description.
58    pub specimen_description: Option<String>,
59    /// Imaged volume depth in millimeters.
60    pub imaged_volume_depth_mm: Option<f64>,
61    /// DICOM focus method value.
62    pub focus_method: Option<String>,
63}
64
65impl DicomMetadata {
66    /// Return deterministic placeholder metadata for non-clinical research exports.
67    pub fn research_placeholder() -> Self {
68        Self {
69            patient_name: Some("RESEARCH^PLACEHOLDER".into()),
70            patient_id: Some("RESEARCH".into()),
71            patient_birth_date: Some(String::new()),
72            patient_sex: Some(String::new()),
73            accession_number: Some("RESEARCH".into()),
74            study_id: Some("1".into()),
75            study_date: Some("19700101".into()),
76            study_time: Some("000000".into()),
77            study_description: Some("Research placeholder WSI export".into()),
78            referring_physician_name: Some(String::new()),
79            laterality: Some(String::new()),
80            manufacturer: Some("wsi-dicom".into()),
81            manufacturer_model_name: Some("wsi-dicom".into()),
82            device_serial_number: Some("RESEARCH".into()),
83            software_versions: Some(env!("CARGO_PKG_VERSION").into()),
84            content_date: Some("19700101".into()),
85            content_time: Some("000000".into()),
86            acquisition_date_time: Some("19700101000000".into()),
87            container_identifier: Some("RESEARCH-CONTAINER".into()),
88            specimen_identifier: Some("RESEARCH-SPECIMEN".into()),
89            specimen_description: Some("Research placeholder specimen".into()),
90            imaged_volume_depth_mm: Some(0.001),
91            focus_method: Some("AUTO".into()),
92            study_instance_uid: None,
93        }
94    }
95
96    /// Map supported Patient, Specimen, ServiceRequest, and DiagnosticReport fields from FHIR R4 JSON.
97    pub fn from_fhir_r4_bundle(value: &serde_json::Value) -> Result<Self, Error> {
98        let mut metadata = Self::default();
99        let resources = fhir_resources(value)?;
100        let report = anchored_diagnostic_report(&resources)?;
101        map_fhir_diagnostic_report(report, &mut metadata);
102
103        let subject = required_reference(report, "subject", "FHIR DiagnosticReport")?;
104        let patient = resolve_unique_fhir_reference(&resources, subject, "Patient")?;
105        map_fhir_patient(patient, &mut metadata);
106
107        let specimen_ref =
108            required_reference_array_item(report, "specimen", "FHIR DiagnosticReport")?;
109        let specimen = resolve_unique_fhir_reference(&resources, specimen_ref, "Specimen")?;
110        map_fhir_specimen(specimen, &mut metadata);
111
112        let based_on_ref =
113            required_reference_array_item(report, "basedOn", "FHIR DiagnosticReport")?;
114        let service_request =
115            resolve_unique_fhir_reference(&resources, based_on_ref, "ServiceRequest")?;
116        map_fhir_service_request(service_request, &mut metadata);
117
118        metadata.validate_strict()?;
119        Ok(metadata)
120    }
121
122    /// Validate that required strict metadata fields are present.
123    pub fn validate_strict(&self) -> Result<(), Error> {
124        if self.patient_id.as_deref().unwrap_or_default().is_empty() {
125            return Err(Error::Metadata {
126                reason: "strict metadata requires patient_id".into(),
127            });
128        }
129        if self.patient_name.as_deref().unwrap_or_default().is_empty() {
130            return Err(Error::Metadata {
131                reason: "strict metadata requires patient_name".into(),
132            });
133        }
134        Ok(())
135    }
136
137    pub(crate) fn validated_for_writer(&self) -> Result<ValidatedDicomMetadata<'_>, Error> {
138        self.validate_strict()?;
139        validate_optional_vr("patient_name", "PN", self.patient_name.as_deref(), 64)?;
140        validate_optional_vr("patient_id", "LO", self.patient_id.as_deref(), 64)?;
141        validate_optional_da("patient_birth_date", self.patient_birth_date.as_deref())?;
142        validate_optional_cs("patient_sex", self.patient_sex.as_deref())?;
143        validate_optional_vr(
144            "accession_number",
145            "SH",
146            self.accession_number.as_deref(),
147            16,
148        )?;
149        validate_optional_ui("study_instance_uid", self.study_instance_uid.as_deref())?;
150        validate_optional_vr("study_id", "SH", self.study_id.as_deref(), 16)?;
151        validate_optional_da("study_date", self.study_date.as_deref())?;
152        validate_optional_tm("study_time", self.study_time.as_deref())?;
153        validate_optional_vr(
154            "study_description",
155            "LO",
156            self.study_description.as_deref(),
157            64,
158        )?;
159        validate_optional_vr(
160            "referring_physician_name",
161            "PN",
162            self.referring_physician_name.as_deref(),
163            64,
164        )?;
165        validate_optional_cs("laterality", self.laterality.as_deref())?;
166        validate_optional_vr("manufacturer", "LO", self.manufacturer.as_deref(), 64)?;
167        validate_optional_vr(
168            "manufacturer_model_name",
169            "LO",
170            self.manufacturer_model_name.as_deref(),
171            64,
172        )?;
173        validate_optional_vr(
174            "device_serial_number",
175            "LO",
176            self.device_serial_number.as_deref(),
177            64,
178        )?;
179        validate_optional_vr(
180            "software_versions",
181            "LO",
182            self.software_versions.as_deref(),
183            64,
184        )?;
185        validate_optional_da("content_date", self.content_date.as_deref())?;
186        validate_optional_tm("content_time", self.content_time.as_deref())?;
187        validate_optional_dt(
188            "acquisition_date_time",
189            self.acquisition_date_time.as_deref(),
190        )?;
191        validate_optional_vr(
192            "container_identifier",
193            "LO",
194            self.container_identifier.as_deref(),
195            64,
196        )?;
197        validate_optional_vr(
198            "specimen_identifier",
199            "LO",
200            self.specimen_identifier.as_deref(),
201            64,
202        )?;
203        validate_optional_vr(
204            "specimen_description",
205            "LO",
206            self.specimen_description.as_deref(),
207            64,
208        )?;
209        validate_optional_cs("focus_method", self.focus_method.as_deref())?;
210        Ok(ValidatedDicomMetadata { metadata: self })
211    }
212}
213
214#[derive(Debug)]
215pub(crate) struct ValidatedDicomMetadata<'a> {
216    metadata: &'a DicomMetadata,
217}
218
219impl std::ops::Deref for ValidatedDicomMetadata<'_> {
220    type Target = DicomMetadata;
221
222    fn deref(&self) -> &Self::Target {
223        self.metadata
224    }
225}
226
227impl<'a> ValidatedDicomMetadata<'a> {
228    pub(crate) fn as_metadata(&self) -> &'a DicomMetadata {
229        self.metadata
230    }
231}
232
233/// Source of metadata for the DICOM export request.
234#[derive(Debug, Clone, PartialEq)]
235#[non_exhaustive]
236pub enum MetadataSource {
237    /// Caller-provided metadata that must satisfy strict validation.
238    Strict(Box<DicomMetadata>),
239    /// Deterministic non-clinical metadata suitable for tests and research placeholders.
240    ResearchPlaceholder,
241    /// FHIR R4 JSON mapped into DICOM metadata before strict validation.
242    FhirR4Bundle(serde_json::Value),
243}
244
245impl MetadataSource {
246    /// Read a bounded JSON file and map it into FHIR R4 or strict DICOM metadata.
247    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, Error> {
248        let path = path.as_ref();
249        let file = std::fs::File::open(path).map_err(|source| Error::Io {
250            path: path.to_path_buf(),
251            source,
252        })?;
253        let mut limited = file.take(METADATA_JSON_MAX_BYTES.saturating_add(1));
254        let mut bytes = Vec::new();
255        limited
256            .read_to_end(&mut bytes)
257            .map_err(|source| Error::Io {
258                path: path.to_path_buf(),
259                source,
260            })?;
261        if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > METADATA_JSON_MAX_BYTES {
262            return Err(Error::Metadata {
263                reason: format!(
264                    "metadata JSON {} exceeds {} byte limit",
265                    path.display(),
266                    METADATA_JSON_MAX_BYTES
267                ),
268            });
269        }
270        let value = serde_json::from_slice(&bytes).map_err(|source| Error::Json {
271            path: path.to_path_buf(),
272            source,
273        })?;
274        Self::from_json_value(value).map_err(|source| Error::Json {
275            path: path.to_path_buf(),
276            source,
277        })
278    }
279
280    /// Map metadata JSON into either FHIR R4 or strict DICOM metadata input.
281    pub fn from_json_value(value: serde_json::Value) -> Result<Self, serde_json::Error> {
282        if metadata_json_is_supported_fhir(&value) {
283            Ok(Self::FhirR4Bundle(value))
284        } else {
285            let metadata: DicomMetadata = serde_json::from_value(value)?;
286            Ok(Self::Strict(Box::new(metadata)))
287        }
288    }
289
290    pub(crate) fn resolve(&self) -> Result<DicomMetadata, Error> {
291        match self {
292            Self::Strict(metadata) => {
293                metadata.validate_strict()?;
294                Ok(metadata.as_ref().clone())
295            }
296            Self::ResearchPlaceholder => Ok(DicomMetadata::research_placeholder()),
297            Self::FhirR4Bundle(bundle) => DicomMetadata::from_fhir_r4_bundle(bundle),
298        }
299    }
300}
301
302fn metadata_json_is_supported_fhir(value: &serde_json::Value) -> bool {
303    matches!(
304        value
305            .get("resourceType")
306            .and_then(serde_json::Value::as_str),
307        Some("Bundle" | "Patient" | "Specimen" | "ServiceRequest" | "DiagnosticReport")
308    )
309}
310
311fn fhir_resources(value: &serde_json::Value) -> Result<Vec<&serde_json::Value>, Error> {
312    match value
313        .get("resourceType")
314        .and_then(serde_json::Value::as_str)
315    {
316        Some("Bundle") => Ok(value
317            .get("entry")
318            .and_then(serde_json::Value::as_array)
319            .ok_or_else(|| Error::Metadata {
320                reason: "FHIR Bundle is missing entry array".into(),
321            })?
322            .iter()
323            .filter_map(|entry| entry.get("resource"))
324            .collect()),
325        Some(_) => Ok(vec![value]),
326        None => Err(Error::Metadata {
327            reason: "FHIR JSON is missing resourceType".into(),
328        }),
329    }
330}
331
332fn anchored_diagnostic_report<'a>(
333    resources: &'a [&'a serde_json::Value],
334) -> Result<&'a serde_json::Value, Error> {
335    let reports = resources
336        .iter()
337        .copied()
338        .filter(|resource| {
339            resource
340                .get("resourceType")
341                .and_then(serde_json::Value::as_str)
342                == Some("DiagnosticReport")
343        })
344        .collect::<Vec<_>>();
345    match reports.as_slice() {
346        [report] => Ok(*report),
347        [] => Err(Error::Metadata {
348            reason: "FHIR metadata requires exactly one DiagnosticReport anchor".into(),
349        }),
350        _ => Err(Error::Metadata {
351            reason: "FHIR metadata contains multiple DiagnosticReport resources".into(),
352        }),
353    }
354}
355
356fn required_reference<'a>(
357    resource: &'a serde_json::Value,
358    field: &str,
359    owner: &str,
360) -> Result<&'a str, Error> {
361    resource
362        .get(field)
363        .and_then(|value| value.get("reference"))
364        .and_then(serde_json::Value::as_str)
365        .filter(|reference| !reference.is_empty())
366        .ok_or_else(|| Error::Metadata {
367            reason: format!("{owner} is missing {field}.reference"),
368        })
369}
370
371fn required_reference_array_item<'a>(
372    resource: &'a serde_json::Value,
373    field: &str,
374    owner: &str,
375) -> Result<&'a str, Error> {
376    let values = resource
377        .get(field)
378        .and_then(serde_json::Value::as_array)
379        .ok_or_else(|| Error::Metadata {
380            reason: format!("{owner} is missing {field} reference array"),
381        })?;
382    if values.len() != 1 {
383        return Err(Error::Metadata {
384            reason: format!("{owner} requires exactly one {field} reference"),
385        });
386    }
387    values[0]
388        .get("reference")
389        .and_then(serde_json::Value::as_str)
390        .filter(|reference| !reference.is_empty())
391        .ok_or_else(|| Error::Metadata {
392            reason: format!("{owner} has {field} entry without reference"),
393        })
394}
395
396fn resolve_unique_fhir_reference<'a>(
397    resources: &'a [&'a serde_json::Value],
398    reference: &str,
399    expected_type: &str,
400) -> Result<&'a serde_json::Value, Error> {
401    let Some((reference_type, reference_id)) = reference.split_once('/') else {
402        return Err(Error::Metadata {
403            reason: format!("FHIR reference {reference:?} must use ResourceType/id form"),
404        });
405    };
406    if reference_type != expected_type {
407        return Err(Error::Metadata {
408            reason: format!(
409                "FHIR reference {reference:?} points to {reference_type}, expected {expected_type}"
410            ),
411        });
412    }
413    let matches = resources
414        .iter()
415        .copied()
416        .filter(|resource| {
417            resource
418                .get("resourceType")
419                .and_then(serde_json::Value::as_str)
420                == Some(expected_type)
421                && resource.get("id").and_then(serde_json::Value::as_str) == Some(reference_id)
422        })
423        .collect::<Vec<_>>();
424    let same_type_count = resources
425        .iter()
426        .filter(|resource| {
427            resource
428                .get("resourceType")
429                .and_then(serde_json::Value::as_str)
430                == Some(expected_type)
431        })
432        .count();
433    if same_type_count > matches.len() {
434        return Err(Error::Metadata {
435            reason: format!(
436                "FHIR metadata contains unreferenced {expected_type} resources beside {reference:?}"
437            ),
438        });
439    }
440    match matches.as_slice() {
441        [resource] => Ok(*resource),
442        [] => Err(Error::Metadata {
443            reason: format!("FHIR reference {reference:?} did not match any bundled resource"),
444        }),
445        _ => Err(Error::Metadata {
446            reason: format!("FHIR reference {reference:?} matched multiple resources"),
447        }),
448    }
449}
450
451fn map_fhir_patient(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
452    metadata.patient_id = first_identifier(resource).or_else(|| json_string(resource, "/id"));
453    metadata.patient_name = resource
454        .get("name")
455        .and_then(serde_json::Value::as_array)
456        .and_then(|names| names.first())
457        .and_then(fhir_human_name_to_pn);
458    metadata.patient_birth_date =
459        json_string(resource, "/birthDate").map(|date| date.replace('-', ""));
460    metadata.patient_sex =
461        json_string(resource, "/gender").and_then(|gender| match gender.as_str() {
462            "male" => Some("M".to_string()),
463            "female" => Some("F".to_string()),
464            "other" => Some("O".to_string()),
465            "unknown" => Some("U".to_string()),
466            _ => None,
467        });
468}
469
470fn map_fhir_specimen(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
471    metadata.specimen_identifier = json_string(resource, "/accessionIdentifier/value")
472        .or_else(|| first_identifier(resource))
473        .or_else(|| json_string(resource, "/id"));
474    if metadata.container_identifier.is_none() {
475        metadata.container_identifier = metadata.specimen_identifier.clone();
476    }
477    metadata.specimen_description = json_string(resource, "/type/text");
478}
479
480fn map_fhir_service_request(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
481    metadata.accession_number = first_identifier(resource)
482        .or_else(|| json_string(resource, "/requisition/value"))
483        .or_else(|| json_string(resource, "/id"));
484    if metadata.study_description.is_none() {
485        metadata.study_description = json_string(resource, "/code/text");
486    }
487}
488
489fn map_fhir_diagnostic_report(resource: &serde_json::Value, metadata: &mut DicomMetadata) {
490    if metadata.study_id.is_none() {
491        metadata.study_id = first_identifier(resource).or_else(|| json_string(resource, "/id"));
492    }
493    metadata.study_description = json_string(resource, "/code/text");
494}
495
496fn first_identifier(resource: &serde_json::Value) -> Option<String> {
497    resource
498        .get("identifier")
499        .and_then(serde_json::Value::as_array)
500        .and_then(|ids| ids.first())
501        .and_then(|id| json_string(id, "/value"))
502}
503
504fn fhir_human_name_to_pn(name: &serde_json::Value) -> Option<String> {
505    let family = name.get("family").and_then(serde_json::Value::as_str)?;
506    let given = name
507        .get("given")
508        .and_then(serde_json::Value::as_array)
509        .map(|values| {
510            values
511                .iter()
512                .filter_map(serde_json::Value::as_str)
513                .collect::<Vec<_>>()
514                .join(" ")
515        })
516        .unwrap_or_default();
517    if given.is_empty() {
518        Some(family.to_string())
519    } else {
520        Some(format!("{family}^{given}"))
521    }
522}
523
524fn json_string(value: &serde_json::Value, pointer: &str) -> Option<String> {
525    value
526        .pointer(pointer)
527        .and_then(serde_json::Value::as_str)
528        .filter(|s| !s.is_empty())
529        .map(ToOwned::to_owned)
530}
531
532fn validate_optional_vr(
533    field: &str,
534    vr: &str,
535    value: Option<&str>,
536    max_chars: usize,
537) -> Result<(), Error> {
538    let Some(value) = value else {
539        return Ok(());
540    };
541    if value.chars().any(is_disallowed_text_control) {
542        return Err(Error::Metadata {
543            reason: format!("{field} contains control characters not allowed in DICOM {vr}"),
544        });
545    }
546    if value.chars().count() > max_chars {
547        return Err(Error::Metadata {
548            reason: format!("{field} exceeds DICOM {vr} limit of {max_chars} characters"),
549        });
550    }
551    Ok(())
552}
553
554fn validate_optional_ui(field: &str, value: Option<&str>) -> Result<(), Error> {
555    let Some(value) = value.filter(|value| !value.is_empty()) else {
556        return Ok(());
557    };
558    if value.len() > 64
559        || value.starts_with('.')
560        || value.ends_with('.')
561        || value.contains("..")
562        || !value
563            .bytes()
564            .all(|byte| byte.is_ascii_digit() || byte == b'.')
565    {
566        return Err(Error::Metadata {
567            reason: format!("{field} must be a valid DICOM UI"),
568        });
569    }
570    Ok(())
571}
572
573fn validate_optional_da(field: &str, value: Option<&str>) -> Result<(), Error> {
574    let Some(value) = value.filter(|value| !value.is_empty()) else {
575        return Ok(());
576    };
577    if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) {
578        return Err(Error::Metadata {
579            reason: format!("{field} must use DICOM DA format YYYYMMDD"),
580        });
581    }
582    let year = parse_decimal_component(&value[0..4]);
583    let month = parse_decimal_component(&value[4..6]);
584    let day = parse_decimal_component(&value[6..8]);
585    validate_date_components(field, year, Some(month), Some(day), "DA")?;
586    Ok(())
587}
588
589fn validate_optional_tm(field: &str, value: Option<&str>) -> Result<(), Error> {
590    let Some(value) = value.filter(|value| !value.is_empty()) else {
591        return Ok(());
592    };
593    let Some((time, fraction)) = split_fraction(value) else {
594        return Err(Error::Metadata {
595            reason: format!("{field} must use DICOM TM format HH[MM[SS[.FFFFFF]]]"),
596        });
597    };
598    if fraction.is_some_and(invalid_fraction)
599        || fraction.is_some() && time.len() != 6
600        || !(2..=6).contains(&time.len())
601        || time.len() % 2 != 0
602        || !time.bytes().all(|byte| byte.is_ascii_digit())
603    {
604        return Err(Error::Metadata {
605            reason: format!("{field} must use DICOM TM format HH[MM[SS[.FFFFFF]]]"),
606        });
607    }
608    validate_time_components(field, time, "TM")?;
609    Ok(())
610}
611
612fn validate_optional_dt(field: &str, value: Option<&str>) -> Result<(), Error> {
613    let Some(value) = value.filter(|value| !value.is_empty()) else {
614        return Ok(());
615    };
616    let Some((date_time_with_fraction, timezone)) = split_dt_timezone(value) else {
617        return Err(Error::Metadata {
618            reason: format!(
619                "{field} must use DICOM DT format YYYY[MM[DD[HH[MM[SS[.FFFFFF]]]]]][+/-ZZZZ]"
620            ),
621        });
622    };
623    if let Some(timezone) = timezone {
624        validate_dt_timezone(field, timezone)?;
625    }
626    let Some((date_time, fraction)) = split_fraction(date_time_with_fraction) else {
627        return Err(Error::Metadata {
628            reason: format!(
629                "{field} must use DICOM DT format YYYY[MM[DD[HH[MM[SS[.FFFFFF]]]]]][+/-ZZZZ]"
630            ),
631        });
632    };
633    if !(4..=14).contains(&date_time.len())
634        || date_time.len() % 2 != 0
635        || !date_time.bytes().all(|byte| byte.is_ascii_digit())
636        || fraction.is_some_and(invalid_fraction)
637        || fraction.is_some() && date_time.len() != 14
638        || value.chars().any(is_disallowed_text_control)
639    {
640        return Err(Error::Metadata {
641            reason: format!(
642                "{field} must use DICOM DT format YYYY[MM[DD[HH[MM[SS[.FFFFFF]]]]]][+/-ZZZZ]"
643            ),
644        });
645    }
646    validate_dt_components(field, date_time)?;
647    Ok(())
648}
649
650fn split_fraction(value: &str) -> Option<(&str, Option<&str>)> {
651    let mut parts = value.split('.');
652    let main = parts.next().unwrap_or_default();
653    let fraction = parts.next();
654    if parts.next().is_some() {
655        return None;
656    }
657    Some((main, fraction))
658}
659
660fn invalid_fraction(fraction: &str) -> bool {
661    fraction.is_empty() || fraction.len() > 6 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
662}
663
664fn split_dt_timezone(value: &str) -> Option<(&str, Option<&str>)> {
665    let mut timezone_start = None;
666    for (idx, byte) in value.bytes().enumerate() {
667        if (byte == b'+' || byte == b'-') && timezone_start.replace(idx).is_some() {
668            return None;
669        }
670    }
671    match timezone_start {
672        Some(idx) if idx > 0 => Some((&value[..idx], Some(&value[idx..]))),
673        Some(_) => None,
674        None => Some((value, None)),
675    }
676}
677
678fn validate_date_components(
679    field: &str,
680    year: u32,
681    month: Option<u32>,
682    day: Option<u32>,
683    vr: &str,
684) -> Result<(), Error> {
685    if year == 0 {
686        return Err(Error::Metadata {
687            reason: format!("{field} has invalid DICOM {vr} year"),
688        });
689    }
690    let Some(month) = month else {
691        return Ok(());
692    };
693    if !(1..=12).contains(&month) {
694        return Err(Error::Metadata {
695            reason: format!("{field} has invalid DICOM {vr} month"),
696        });
697    }
698    let Some(day) = day else {
699        return Ok(());
700    };
701    let max_day = days_in_month(year, month);
702    if day == 0 || day > max_day {
703        return Err(Error::Metadata {
704            reason: format!("{field} has invalid DICOM {vr} day"),
705        });
706    }
707    Ok(())
708}
709
710fn validate_time_components(field: &str, time: &str, vr: &str) -> Result<(), Error> {
711    let hour = parse_decimal_component(&time[0..2]);
712    if hour > 23 {
713        return Err(Error::Metadata {
714            reason: format!("{field} has invalid DICOM {vr} hour"),
715        });
716    }
717    if time.len() >= 4 {
718        let minute = parse_decimal_component(&time[2..4]);
719        if minute > 59 {
720            return Err(Error::Metadata {
721                reason: format!("{field} has invalid DICOM {vr} minute"),
722            });
723        }
724    }
725    if time.len() >= 6 {
726        let second = parse_decimal_component(&time[4..6]);
727        if second > 59 {
728            return Err(Error::Metadata {
729                reason: format!("{field} has invalid DICOM {vr} second"),
730            });
731        }
732    }
733    Ok(())
734}
735
736fn validate_dt_components(field: &str, date_time: &str) -> Result<(), Error> {
737    let year = parse_decimal_component(&date_time[0..4]);
738    let month = (date_time.len() >= 6).then(|| parse_decimal_component(&date_time[4..6]));
739    let day = (date_time.len() >= 8).then(|| parse_decimal_component(&date_time[6..8]));
740    validate_date_components(field, year, month, day, "DT")?;
741    if date_time.len() >= 10 {
742        validate_time_components(field, &date_time[8..], "DT")?;
743    }
744    Ok(())
745}
746
747fn validate_dt_timezone(field: &str, timezone: &str) -> Result<(), Error> {
748    let bytes = timezone.as_bytes();
749    if timezone.len() != 5
750        || !matches!(bytes.first(), Some(b'+' | b'-'))
751        || !bytes[1..].iter().all(u8::is_ascii_digit)
752    {
753        return Err(Error::Metadata {
754            reason: format!("{field} must use DICOM DT timezone format +/-ZZZZ"),
755        });
756    }
757    let hour = parse_decimal_component(&timezone[1..3]);
758    let minute = parse_decimal_component(&timezone[3..5]);
759    if hour > 14 || (hour == 14 && minute != 0) || minute > 59 {
760        return Err(Error::Metadata {
761            reason: format!("{field} has invalid DICOM DT timezone offset"),
762        });
763    }
764    Ok(())
765}
766
767fn parse_decimal_component(value: &str) -> u32 {
768    value.parse::<u32>().unwrap_or(0)
769}
770
771fn days_in_month(year: u32, month: u32) -> u32 {
772    match month {
773        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
774        4 | 6 | 9 | 11 => 30,
775        2 if is_leap_year(year) => 29,
776        2 => 28,
777        _ => 0,
778    }
779}
780
781fn is_leap_year(year: u32) -> bool {
782    year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400)
783}
784
785fn validate_optional_cs(field: &str, value: Option<&str>) -> Result<(), Error> {
786    let Some(value) = value.filter(|value| !value.is_empty()) else {
787        return Ok(());
788    };
789    if value.chars().count() > 16
790        || !value.bytes().all(|byte| {
791            byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' || byte == b' '
792        })
793    {
794        return Err(Error::Metadata {
795            reason: format!("{field} must be a valid DICOM CS value"),
796        });
797    }
798    Ok(())
799}
800
801fn is_disallowed_text_control(ch: char) -> bool {
802    ch.is_control() && ch != '\t' && ch != '\n' && ch != '\r'
803}
804
805#[cfg(test)]
806mod tests {
807    use super::{DicomMetadata, MetadataSource, METADATA_JSON_MAX_BYTES};
808
809    #[test]
810    fn metadata_source_from_json_file_reads_valid_input_and_rejects_oversize_input() {
811        let directory = tempfile::tempdir().unwrap();
812        let valid = directory.path().join("metadata.json");
813        std::fs::write(&valid, br#"{"patient_id":"P-1","patient_name":"DOE^JANE"}"#).unwrap();
814        assert!(matches!(
815            MetadataSource::from_json_file(&valid).unwrap(),
816            MetadataSource::Strict(_)
817        ));
818
819        let oversized = directory.path().join("oversized.json");
820        let file = std::fs::File::create(&oversized).unwrap();
821        file.set_len(METADATA_JSON_MAX_BYTES + 1).unwrap();
822        let error = MetadataSource::from_json_file(&oversized).unwrap_err();
823        assert!(error.to_string().contains("exceeds"));
824    }
825
826    #[test]
827    fn metadata_source_from_json_value_detects_supported_fhir_resources() {
828        let value = serde_json::json!({
829            "resourceType": "Patient",
830            "id": "patient-1",
831            "name": [{"family": "Doe", "given": ["Jane"]}]
832        });
833
834        let source = MetadataSource::from_json_value(value.clone()).unwrap();
835
836        assert_eq!(source, MetadataSource::FhirR4Bundle(value));
837    }
838
839    #[test]
840    fn metadata_source_from_json_value_parses_strict_dicom_metadata() {
841        let value = serde_json::json!({
842            "patient_id": "P-1",
843            "patient_name": "DOE^JANE",
844            "study_id": "S-1"
845        });
846
847        let source = MetadataSource::from_json_value(value).unwrap();
848
849        let MetadataSource::Strict(metadata) = source else {
850            panic!("expected strict DICOM metadata");
851        };
852        assert_eq!(
853            metadata.as_ref(),
854            &DicomMetadata {
855                patient_id: Some("P-1".to_string()),
856                patient_name: Some("DOE^JANE".to_string()),
857                study_id: Some("S-1".to_string()),
858                ..DicomMetadata::default()
859            }
860        );
861    }
862
863    #[test]
864    fn fhir_bundle_rejects_multiple_reports_and_unreferenced_same_type_resources() {
865        let base = serde_json::json!({
866            "resourceType": "Bundle",
867            "entry": [
868                {"resource": {"resourceType": "Patient", "id": "pat-1", "identifier": [{"value": "MRN123"}], "name": [{"family": "Doe"}]}},
869                {"resource": {"resourceType": "Specimen", "id": "spec-1", "identifier": [{"value": "S-42"}]}},
870                {"resource": {"resourceType": "ServiceRequest", "id": "sr-1", "identifier": [{"value": "ORDER-7"}]}},
871                {"resource": {"resourceType": "DiagnosticReport", "id": "dr-1", "subject": {"reference": "Patient/pat-1"}, "specimen": [{"reference": "Specimen/spec-1"}], "basedOn": [{"reference": "ServiceRequest/sr-1"}]}}
872            ]
873        });
874
875        let mut two_reports = base.clone();
876        two_reports["entry"].as_array_mut().unwrap().push(
877            serde_json::json!({"resource": {"resourceType": "DiagnosticReport", "id": "dr-2"}}),
878        );
879        let err = DicomMetadata::from_fhir_r4_bundle(&two_reports).unwrap_err();
880        assert!(err.to_string().contains("multiple DiagnosticReport"));
881
882        let mut two_patients = base;
883        two_patients["entry"]
884            .as_array_mut()
885            .unwrap()
886            .push(serde_json::json!({"resource": {"resourceType": "Patient", "id": "pat-2"}}));
887        let err = DicomMetadata::from_fhir_r4_bundle(&two_patients).unwrap_err();
888        assert!(err.to_string().contains("unreferenced Patient"));
889    }
890
891    #[test]
892    fn writer_metadata_validation_rejects_invalid_vr_values() {
893        let mut metadata = DicomMetadata::research_placeholder();
894        metadata.study_instance_uid = Some("1..2".to_string());
895        assert!(metadata
896            .validated_for_writer()
897            .unwrap_err()
898            .to_string()
899            .contains("study_instance_uid"));
900
901        let mut metadata = DicomMetadata::research_placeholder();
902        metadata.study_date = Some("2026-06-14".to_string());
903        assert!(metadata
904            .validated_for_writer()
905            .unwrap_err()
906            .to_string()
907            .contains("study_date"));
908
909        let mut metadata = DicomMetadata::research_placeholder();
910        metadata.patient_sex = Some("female".to_string());
911        assert!(metadata
912            .validated_for_writer()
913            .unwrap_err()
914            .to_string()
915            .contains("patient_sex"));
916
917        let mut metadata = DicomMetadata::research_placeholder();
918        metadata.study_description = Some("A".repeat(65));
919        assert!(metadata
920            .validated_for_writer()
921            .unwrap_err()
922            .to_string()
923            .contains("study_description"));
924
925        let mut metadata = DicomMetadata::research_placeholder();
926        metadata.patient_name = Some("BAD\u{0007}NAME".to_string());
927        assert!(metadata
928            .validated_for_writer()
929            .unwrap_err()
930            .to_string()
931            .contains("patient_name"));
932    }
933
934    #[test]
935    fn writer_metadata_validation_accepts_semantic_da_tm_dt_values() {
936        let mut metadata = DicomMetadata::research_placeholder();
937        metadata.patient_birth_date = Some("20240229".to_string());
938        metadata.study_date = Some("20260614".to_string());
939        metadata.study_time = Some("235959.123456".to_string());
940        metadata.content_time = Some("00".to_string());
941        metadata.acquisition_date_time = Some("20240229235959.123456+0530".to_string());
942
943        metadata.validated_for_writer().unwrap();
944    }
945
946    #[test]
947    fn validate_strict_preserves_required_field_contract() {
948        let mut metadata = DicomMetadata::research_placeholder();
949        metadata.study_date = Some("20261301".to_string());
950
951        metadata.validate_strict().unwrap();
952    }
953
954    #[test]
955    fn writer_metadata_validation_rejects_invalid_semantic_da_tm_dt_values() {
956        let mut metadata = DicomMetadata::research_placeholder();
957        metadata.patient_birth_date = Some("20230229".to_string());
958        assert!(metadata
959            .validated_for_writer()
960            .unwrap_err()
961            .to_string()
962            .contains("patient_birth_date"));
963
964        let mut metadata = DicomMetadata::research_placeholder();
965        metadata.study_date = Some("20261301".to_string());
966        assert!(metadata
967            .validated_for_writer()
968            .unwrap_err()
969            .to_string()
970            .contains("study_date"));
971
972        let mut metadata = DicomMetadata::research_placeholder();
973        metadata.study_time = Some("240000".to_string());
974        assert!(metadata
975            .validated_for_writer()
976            .unwrap_err()
977            .to_string()
978            .contains("study_time"));
979
980        let mut metadata = DicomMetadata::research_placeholder();
981        metadata.content_time = Some("235960".to_string());
982        assert!(metadata
983            .validated_for_writer()
984            .unwrap_err()
985            .to_string()
986            .contains("content_time"));
987
988        let mut metadata = DicomMetadata::research_placeholder();
989        metadata.study_time = Some("1200.1".to_string());
990        assert!(metadata
991            .validated_for_writer()
992            .unwrap_err()
993            .to_string()
994            .contains("study_time"));
995
996        let mut metadata = DicomMetadata::research_placeholder();
997        metadata.acquisition_date_time = Some("20260229235959".to_string());
998        assert!(metadata
999            .validated_for_writer()
1000            .unwrap_err()
1001            .to_string()
1002            .contains("acquisition_date_time"));
1003
1004        let mut metadata = DicomMetadata::research_placeholder();
1005        metadata.acquisition_date_time = Some("20260614235959.1234567".to_string());
1006        assert!(metadata
1007            .validated_for_writer()
1008            .unwrap_err()
1009            .to_string()
1010            .contains("acquisition_date_time"));
1011
1012        let mut metadata = DicomMetadata::research_placeholder();
1013        metadata.acquisition_date_time = Some("20260614235959+1401".to_string());
1014        assert!(metadata
1015            .validated_for_writer()
1016            .unwrap_err()
1017            .to_string()
1018            .contains("acquisition_date_time"));
1019    }
1020}