1use std::io::Read;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::Error;
7
8pub const METADATA_JSON_MAX_BYTES: u64 = 16 * 1024 * 1024;
10
11#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct DicomMetadata {
15 pub patient_name: Option<String>,
17 pub patient_id: Option<String>,
19 pub patient_birth_date: Option<String>,
21 pub patient_sex: Option<String>,
23 pub accession_number: Option<String>,
25 pub study_instance_uid: Option<String>,
27 pub study_id: Option<String>,
29 pub study_date: Option<String>,
31 pub study_time: Option<String>,
33 pub study_description: Option<String>,
35 pub referring_physician_name: Option<String>,
37 pub laterality: Option<String>,
39 pub manufacturer: Option<String>,
41 pub manufacturer_model_name: Option<String>,
43 pub device_serial_number: Option<String>,
45 pub software_versions: Option<String>,
47 pub content_date: Option<String>,
49 pub content_time: Option<String>,
51 pub acquisition_date_time: Option<String>,
53 pub container_identifier: Option<String>,
55 pub specimen_identifier: Option<String>,
57 pub specimen_description: Option<String>,
59 pub imaged_volume_depth_mm: Option<f64>,
61 pub focus_method: Option<String>,
63}
64
65impl DicomMetadata {
66 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 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 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#[derive(Debug, Clone, PartialEq)]
235#[non_exhaustive]
236pub enum MetadataSource {
237 Strict(Box<DicomMetadata>),
239 ResearchPlaceholder,
241 FhirR4Bundle(serde_json::Value),
243}
244
245impl MetadataSource {
246 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 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}