Skip to main content

mwlkit/
map.rs

1//! The mapping table, one function per DICOM module of the Modality
2//! Worklist Information Model (PS3.4 Table K.6-1).
3//!
4//! Sources, cited per line as `IHE` (IHE Radiology Technical Framework
5//! Volume 2, RAD-4 "Procedure Scheduled", HL7-to-DICOM mapping tables in
6//! §4.4), `dcm4che` (dcm4chee-arc's HL7-to-MWL mapping), `PS3.x` (the DICOM
7//! standard part) or `unsourced` (a choice of this crate, explained).
8//! Attribute types (1, 2, 3) are those of Table K.6-1.
9
10use crate::input::{Input, Options};
11use crate::limits::{self, bounded, strict};
12use crate::pn;
13use crate::uid;
14use crate::{MwlError, StudyUid, Warning, WorklistItem};
15use dicom_core::value::DataSetSequence;
16use dicom_core::{DataElement, PrimitiveValue, Tag, VR};
17use dicom_dictionary_std::tags;
18use dicom_object::InMemDicomObject;
19use hl7kit::{Message, Segment};
20
21/// Build the worklist item for one order.
22pub fn worklist_item(input: &Input<'_>, options: &Options) -> Result<WorklistItem, MwlError> {
23    let msg = input.message;
24    let mut ds = InMemDicomObject::new_empty();
25    let mut warnings = Vec::new();
26
27    character_set(&mut ds, msg, options, &mut warnings);
28    patient(&mut ds, msg, options, &mut warnings)?;
29    imaging_service_request(&mut ds, input, options, &mut warnings)?;
30    let study_uid = requested_procedure(&mut ds, input, options, &mut warnings)?;
31    scheduled_procedure_steps(&mut ds, input, options, &mut warnings)?;
32    visit(&mut ds, msg);
33    // One note per distinct decision: the station default repeats per SPS.
34    warnings.dedup();
35
36    Ok(WorklistItem {
37        dataset: ds,
38        study_uid,
39        warnings,
40    })
41}
42
43// ---------------------------------------------------------------------------
44// Data set level
45// ---------------------------------------------------------------------------
46
47/// (0008,0005) Specific Character Set. IHE: from MSH-18. Unknown or absent
48/// values become UTF-8 with a warning; `ASCII` means the default repertoire
49/// and no attribute at all (PS3.5 6.1.2.5).
50fn character_set(
51    ds: &mut InMemDicomObject,
52    msg: &Message,
53    options: &Options,
54    w: &mut Vec<Warning>,
55) {
56    if let Some(cs) = &options.character_set {
57        put_str(ds, tags::SPECIFIC_CHARACTER_SET, VR::CS, cs);
58        return;
59    }
60    let msh18 = field(msg, "MSH-18.1");
61    let mapped = msh18.as_deref().map(|v| v.trim().to_ascii_uppercase());
62    let term = match mapped.as_deref() {
63        Some("ASCII") => return,
64        Some("8859/1") => Some("ISO_IR 100"),
65        Some("8859/2") => Some("ISO_IR 101"),
66        Some("8859/5") => Some("ISO_IR 144"),
67        Some("8859/15") => Some("ISO_IR 203"),
68        Some("UNICODE UTF-8") | Some("UNICODE") | Some("UTF-8") => Some("ISO_IR 192"),
69        _ => None,
70    };
71    match term {
72        Some(t) => put_str(ds, tags::SPECIFIC_CHARACTER_SET, VR::CS, t),
73        None => {
74            w.push(Warning::CharacterSetAssumed {
75                msh18,
76                used: "ISO_IR 192".into(),
77            });
78            put_str(ds, tags::SPECIFIC_CHARACTER_SET, VR::CS, "ISO_IR 192");
79        }
80    }
81}
82
83// ---------------------------------------------------------------------------
84// Patient Identification and Patient Demographic modules
85// ---------------------------------------------------------------------------
86
87fn patient(
88    ds: &mut InMemDicomObject,
89    msg: &Message,
90    options: &Options,
91    w: &mut Vec<Warning>,
92) -> Result<(), MwlError> {
93    // (0010,0020) Patient ID, LO, Type 1. IHE: PID-3.1. No substitute.
94    let id = field(msg, "PID-3.1").ok_or(MwlError::MissingPatientId)?;
95    let id = bounded(tags::PATIENT_ID, limits::LO, id, options.length_policy, w)?;
96    put_str(ds, tags::PATIENT_ID, VR::LO, &id);
97    // (0010,0021) Issuer of Patient ID, LO, Type 3. IHE: PID-3.4 (assigning authority namespace).
98    if let Some(issuer) = field(msg, "PID-3.4") {
99        let issuer = bounded(
100            tags::ISSUER_OF_PATIENT_ID,
101            limits::LO,
102            issuer,
103            options.length_policy,
104            w,
105        )?;
106        put_str(ds, tags::ISSUER_OF_PATIENT_ID, VR::LO, &issuer);
107    }
108
109    // (0010,0010) Patient's Name, PN, Type 2. IHE: PID-5; component order per pn.rs.
110    match field(msg, "PID-5").and_then(|v| pn::from_xpn(&v)) {
111        Some(name) => {
112            if name.reordered {
113                w.push(Warning::NameComponentsReordered);
114            }
115            let pn = bounded(
116                tags::PATIENT_NAME,
117                limits::PN,
118                name.pn,
119                options.length_policy,
120                w,
121            )?;
122            put_str(ds, tags::PATIENT_NAME, VR::PN, &pn);
123        }
124        None => put_empty(ds, tags::PATIENT_NAME, VR::PN),
125    }
126
127    // (0010,0030) Patient's Birth Date, DA, Type 2. IHE: PID-7, date part only.
128    match field(msg, "PID-7").and_then(|v| hl7_date(&v)) {
129        Some(d) => put_str(ds, tags::PATIENT_BIRTH_DATE, VR::DA, &d),
130        None => put_empty(ds, tags::PATIENT_BIRTH_DATE, VR::DA),
131    }
132
133    // (0010,0040) Patient's Sex, CS, Type 2. IHE: PID-8; M/F/O pass, U and
134    // absent are empty without comment, anything else empty with a warning
135    // (HL7 table 0001 also has A ambiguous and N not applicable; DICOM has
136    // no equivalent).
137    let sex = field(msg, "PID-8").map(|s| s.to_ascii_uppercase());
138    match sex.as_deref() {
139        Some("M") | Some("F") | Some("O") => {
140            put_str(ds, tags::PATIENT_SEX, VR::CS, sex.as_deref().unwrap_or(""));
141        }
142        Some("U") | None => put_empty(ds, tags::PATIENT_SEX, VR::CS),
143        Some(other) => {
144            w.push(Warning::SexMappedToOther {
145                hl7: other.to_string(),
146            });
147            put_empty(ds, tags::PATIENT_SEX, VR::CS);
148        }
149    }
150    // (0010,1030) Patient's Weight from an OBX with LOINC 29463-7, and
151    // (0010,2000)/(0010,2110) Medical Alerts/Allergies from AL1: Type 3,
152    // not mapped in this version.
153    Ok(())
154}
155
156// ---------------------------------------------------------------------------
157// Imaging Service Request module
158// ---------------------------------------------------------------------------
159
160fn imaging_service_request(
161    ds: &mut InMemDicomObject,
162    input: &Input<'_>,
163    options: &Options,
164    w: &mut Vec<Warning>,
165) -> Result<(), MwlError> {
166    let msg = input.message;
167    // (0008,0050) Accession Number, SH, Type 2. IHE: OBR-18 (Placer Field 1);
168    // hl7kit falls back to IPC-1.1. SH allows 16 characters; longer RIS
169    // accession numbers follow the length policy.
170    match &input.order.accession {
171        Some(acc) => {
172            let acc = bounded(
173                tags::ACCESSION_NUMBER,
174                limits::SH,
175                acc.clone(),
176                options.length_policy,
177                w,
178            )?;
179            put_str(ds, tags::ACCESSION_NUMBER, VR::SH, &acc);
180        }
181        None => put_empty(ds, tags::ACCESSION_NUMBER, VR::SH),
182    }
183    // (0040,2016) Placer Order Number, LO, Type 3. IHE: ORC-2.1.
184    if let Some(v) = field(msg, "ORC-2.1") {
185        let v = bounded(
186            tags::PLACER_ORDER_NUMBER_IMAGING_SERVICE_REQUEST,
187            limits::LO,
188            v,
189            options.length_policy,
190            w,
191        )?;
192        put_str(
193            ds,
194            tags::PLACER_ORDER_NUMBER_IMAGING_SERVICE_REQUEST,
195            VR::LO,
196            &v,
197        );
198    }
199    // (0040,2017) Filler Order Number, LO, Type 3. IHE: ORC-3.1.
200    if let Some(v) = field(msg, "ORC-3.1") {
201        let v = bounded(
202            tags::FILLER_ORDER_NUMBER_IMAGING_SERVICE_REQUEST,
203            limits::LO,
204            v,
205            options.length_policy,
206            w,
207        )?;
208        put_str(
209            ds,
210            tags::FILLER_ORDER_NUMBER_IMAGING_SERVICE_REQUEST,
211            VR::LO,
212            &v,
213        );
214    }
215    // (0008,0090) Referring Physician's Name, PN, Type 2. IHE: PV1-8
216    // (Referring Doctor); dcm4che also accepts ORC-12 when PV1-8 is absent.
217    let referring = field(msg, "PV1-8")
218        .or_else(|| field(msg, "ORC-12"))
219        .and_then(|v| pn::from_xcn(&v));
220    match referring {
221        Some(name) => {
222            let v = bounded(
223                tags::REFERRING_PHYSICIAN_NAME,
224                limits::PN,
225                name.pn,
226                options.length_policy,
227                w,
228            )?;
229            put_str(ds, tags::REFERRING_PHYSICIAN_NAME, VR::PN, &v);
230        }
231        None => put_empty(ds, tags::REFERRING_PHYSICIAN_NAME, VR::PN),
232    }
233    // (0032,1032) Requesting Physician, PN, Type 3. IHE: OBR-16 (Ordering
234    // Provider); ORC-12 carries the same provider.
235    if let Some(name) = field(msg, "OBR-16")
236        .or_else(|| field(msg, "ORC-12"))
237        .and_then(|v| pn::from_xcn(&v))
238    {
239        let v = bounded(
240            tags::REQUESTING_PHYSICIAN,
241            limits::PN,
242            name.pn,
243            options.length_policy,
244            w,
245        )?;
246        put_str(ds, tags::REQUESTING_PHYSICIAN, VR::PN, &v);
247    }
248    // (0008,0080) Institution Name: MSH-4 is a facility code, not a name;
249    // Type 3, omitted (unsourced decision: nothing in the message says
250    // whether MSH-4 is human-readable).
251    // (0040,2400) Imaging Service Request Comments from NTE: Type 3, not
252    // mapped in this version.
253    Ok(())
254}
255
256// ---------------------------------------------------------------------------
257// Requested Procedure module
258// ---------------------------------------------------------------------------
259
260fn requested_procedure(
261    ds: &mut InMemDicomObject,
262    input: &Input<'_>,
263    options: &Options,
264    w: &mut Vec<Warning>,
265) -> Result<StudyUid, MwlError> {
266    let msg = input.message;
267    // (0040,1001) Requested Procedure ID, SH, Type 1. IHE: OBR-19 (Placer
268    // Field 2); hl7kit falls back to IPC-2.1. When both are absent the
269    // placer order number stands in, and the substitution is reported
270    // (dcm4che does the same).
271    let rp_id = match &input.order.procedure_id {
272        Some(v) => v.clone(),
273        None => {
274            let placer = field(msg, "ORC-2.1").or_else(|| field(msg, "OBR-2.1"));
275            w.push(Warning::MissingType1 {
276                tag: tags::REQUESTED_PROCEDURE_ID,
277                substituted: placer
278                    .as_ref()
279                    .map(|p| format!("placer order number {p} (ORC-2.1)")),
280            });
281            placer.ok_or(MwlError::MissingRequestedProcedureId)?
282        }
283    };
284    let rp_id = bounded(
285        tags::REQUESTED_PROCEDURE_ID,
286        limits::SH,
287        rp_id,
288        options.length_policy,
289        w,
290    )?;
291    put_str(ds, tags::REQUESTED_PROCEDURE_ID, VR::SH, &rp_id);
292
293    // (0020,000D) Study Instance UID, UI, Type 1. See uid.rs.
294    let (value, origin, warning) = uid::resolve(input.order, &options.uid_policy)?;
295    let value = strict(tags::STUDY_INSTANCE_UID, limits::UI, value)?;
296    if let Some(warning) = warning {
297        w.push(warning);
298    }
299    put_str(ds, tags::STUDY_INSTANCE_UID, VR::UI, &value);
300
301    // (0032,1060) Requested Procedure Description, LO. IHE: OBR-4.2. One of
302    // description or code sequence is required.
303    if let Some(desc) = field(msg, "OBR-4.2") {
304        let desc = bounded(
305            tags::REQUESTED_PROCEDURE_DESCRIPTION,
306            limits::LO,
307            desc,
308            options.length_policy,
309            w,
310        )?;
311        put_str(ds, tags::REQUESTED_PROCEDURE_DESCRIPTION, VR::LO, &desc);
312    }
313    // (0032,1064) Requested Procedure Code Sequence, SQ. IHE: OBR-4.1 code,
314    // OBR-4.2 meaning, OBR-4.3 coding scheme. Only when OBR-4.3 is present:
315    // a coding scheme designator is never invented.
316    if let Some(item) = code_item(msg, "OBR-4", options, w)? {
317        put_seq(ds, tags::REQUESTED_PROCEDURE_CODE_SEQUENCE, vec![item]);
318    }
319    // (0040,1003) Requested Procedure Priority, CS. IHE: OBR-27.6, else
320    // TQ1-9 (HL7 table 0485). S→STAT, A→HIGH, R→ROUTINE per IHE. T
321    // (timing critical)→HIGH and P (preop)→MEDIUM are unsourced choices
322    // of this crate; anything else is omitted.
323    let priority = field(msg, "OBR-27.6").or_else(|| field(msg, "TQ1-9.1"));
324    if let Some(p) = priority.map(|p| p.to_ascii_uppercase()) {
325        let dicom = match p.as_str() {
326            "S" => Some("STAT"),
327            "A" | "T" => Some("HIGH"),
328            "R" => Some("ROUTINE"),
329            "P" => Some("MEDIUM"),
330            _ => None,
331        };
332        if let Some(d) = dicom {
333            put_str(ds, tags::REQUESTED_PROCEDURE_PRIORITY, VR::CS, d);
334        }
335    }
336    // (0008,1110) Referenced Study Sequence, SQ, Type 2: present and empty.
337    ds.put(DataElement::new(
338        tags::REFERENCED_STUDY_SEQUENCE,
339        VR::SQ,
340        DataSetSequence::<InMemDicomObject>::empty(),
341    ));
342
343    Ok(StudyUid { value, origin })
344}
345
346// ---------------------------------------------------------------------------
347// Scheduled Procedure Step module: (0040,0100), one item per IPC segment,
348// or one item from ORC/OBR/TQ1 when the message has no IPC (ORM^O01).
349// ---------------------------------------------------------------------------
350
351fn scheduled_procedure_steps(
352    ds: &mut InMemDicomObject,
353    input: &Input<'_>,
354    options: &Options,
355    w: &mut Vec<Warning>,
356) -> Result<(), MwlError> {
357    let msg = input.message;
358    let ipcs: Vec<Segment<'_>> = msg.segments_named("IPC").collect();
359    let mut items = Vec::new();
360    if ipcs.is_empty() {
361        items.push(sps_item(input, None, 1, options, w)?);
362    } else {
363        for (i, ipc) in ipcs.iter().enumerate() {
364            items.push(sps_item(input, Some(ipc), i + 1, options, w)?);
365        }
366    }
367    put_seq(ds, tags::SCHEDULED_PROCEDURE_STEP_SEQUENCE, items);
368    Ok(())
369}
370
371fn sps_item(
372    input: &Input<'_>,
373    ipc: Option<&Segment<'_>>,
374    index: usize,
375    options: &Options,
376    w: &mut Vec<Warning>,
377) -> Result<InMemDicomObject, MwlError> {
378    let msg = input.message;
379    let ipc_field = |n: usize, c: usize| -> Option<String> {
380        let seg = ipc?;
381        let comp = seg.field(n)?.component(c)?;
382        let v = msg.decode(comp.value()).trim().to_string();
383        (!v.is_empty()).then_some(v)
384    };
385    let mut item = InMemDicomObject::new_empty();
386
387    // (0008,0060) Modality, CS, Type 1. IHE: IPC-5 for OMI, OBR-24
388    // (Diagnostic Service Section ID, HL7 table 0074) for ORM. Table 0074
389    // codes are not DICOM modality codes; the few that map are translated
390    // and the translation reported, anything else is refused. Never guessed
391    // from the procedure text.
392    let modality = match ipc_field(5, 1) {
393        Some(m) => modality_code(&m, w).ok_or(MwlError::MissingModality { found: Some(m) })?,
394        None => {
395            let obr24 = field(msg, "OBR-24");
396            match &obr24 {
397                Some(v) => modality_code(v, w).ok_or(MwlError::MissingModality {
398                    found: obr24.clone(),
399                })?,
400                None => return Err(MwlError::MissingModality { found: None }),
401            }
402        }
403    };
404    put_str(&mut item, tags::MODALITY, VR::CS, &modality);
405
406    // (0040,0001) Scheduled Station AE Title, AE, Type 1. IHE: IPC-9. AE is
407    // 16 characters, never truncated; when absent the caller's default is
408    // written and reported.
409    let ae = match ipc_field(9, 1) {
410        Some(ae) => ae,
411        None => {
412            let ae = options
413                .default_station_ae
414                .clone()
415                .ok_or(MwlError::MissingStationAe)?;
416            w.push(Warning::StationAeDefaulted(ae.clone()));
417            ae
418        }
419    };
420    let ae = strict(
421        tags::SCHEDULED_STATION_AE_TITLE,
422        limits::AE,
423        ae.trim().to_string(),
424    )?;
425    put_str(&mut item, tags::SCHEDULED_STATION_AE_TITLE, VR::AE, &ae);
426
427    // (0040,0002)/(0040,0003) SPS Start Date and Time, DA/TM, Type 1. IHE:
428    // OBR-27.4 (Quantity/Timing start), else TQ1-7 in 2.5+ messages.
429    let start = field(msg, "OBR-27.4").or_else(|| field(msg, "TQ1-7"));
430    let (date, time) = match start.as_deref().map(|s| (hl7_date(s), hl7_time(s))) {
431        Some((Some(d), Some(t))) => (d, t),
432        _ => return Err(MwlError::MissingStartDateTime),
433    };
434    put_str(
435        &mut item,
436        tags::SCHEDULED_PROCEDURE_STEP_START_DATE,
437        VR::DA,
438        &date,
439    );
440    put_str(
441        &mut item,
442        tags::SCHEDULED_PROCEDURE_STEP_START_TIME,
443        VR::TM,
444        &time,
445    );
446
447    // (0040,0006) Scheduled Performing Physician's Name, PN, Type 2. IHE:
448    // OBR-34 (Technician, NDL: the name is a CNN in component 1 with
449    // subcomponents id&family&given&middle&suffix&prefix).
450    let performing = field(msg, "OBR-34.1").and_then(|cnn| {
451        let xcn = cnn.split('&').collect::<Vec<_>>().join("^");
452        pn::from_xcn(&xcn)
453    });
454    match performing {
455        Some(name) => {
456            let v = bounded(
457                tags::SCHEDULED_PERFORMING_PHYSICIAN_NAME,
458                limits::PN,
459                name.pn,
460                options.length_policy,
461                w,
462            )?;
463            put_str(
464                &mut item,
465                tags::SCHEDULED_PERFORMING_PHYSICIAN_NAME,
466                VR::PN,
467                &v,
468            );
469        }
470        None => put_empty(&mut item, tags::SCHEDULED_PERFORMING_PHYSICIAN_NAME, VR::PN),
471    }
472
473    // (0040,0007) SPS Description, LO. IHE: OBR-4.2. One of description or
474    // protocol code sequence is required.
475    if let Some(desc) = field(msg, "OBR-4.2") {
476        let desc = bounded(
477            tags::SCHEDULED_PROCEDURE_STEP_DESCRIPTION,
478            limits::LO,
479            desc,
480            options.length_policy,
481            w,
482        )?;
483        put_str(
484            &mut item,
485            tags::SCHEDULED_PROCEDURE_STEP_DESCRIPTION,
486            VR::LO,
487            &desc,
488        );
489    }
490    // (0040,0008) Scheduled Protocol Code Sequence, SQ. IHE: IPC-6 (Protocol
491    // Code), only with a coding scheme.
492    if ipc.is_some() {
493        if let (Some(code), Some(scheme)) = (ipc_field(6, 1), ipc_field(6, 3)) {
494            let mut c = InMemDicomObject::new_empty();
495            put_str(
496                &mut c,
497                tags::CODE_VALUE,
498                VR::SH,
499                &bounded(tags::CODE_VALUE, limits::SH, code, options.length_policy, w)?,
500            );
501            put_str(
502                &mut c,
503                tags::CODING_SCHEME_DESIGNATOR,
504                VR::SH,
505                &bounded(
506                    tags::CODING_SCHEME_DESIGNATOR,
507                    limits::SH,
508                    scheme,
509                    options.length_policy,
510                    w,
511                )?,
512            );
513            if let Some(meaning) = ipc_field(6, 2) {
514                put_str(
515                    &mut c,
516                    tags::CODE_MEANING,
517                    VR::LO,
518                    &bounded(
519                        tags::CODE_MEANING,
520                        limits::LO,
521                        meaning,
522                        options.length_policy,
523                        w,
524                    )?,
525                );
526            }
527            put_seq(&mut item, tags::SCHEDULED_PROTOCOL_CODE_SEQUENCE, vec![c]);
528        }
529    }
530
531    // (0040,0009) SPS ID, SH, Type 1. IHE: IPC-4, else OBR-20 (Filler Field
532    // 1). When both are absent the Requested Procedure ID with a step index
533    // stands in, and the substitution is reported (unsourced).
534    let sps_id = match ipc_field(4, 1).or_else(|| field(msg, "OBR-20")) {
535        Some(v) => v,
536        None => {
537            let rp = input
538                .order
539                .procedure_id
540                .clone()
541                .or_else(|| field(msg, "ORC-2.1"))
542                .unwrap_or_else(|| "SPS".to_string());
543            let id = format!("{rp}-{index}");
544            w.push(Warning::MissingType1 {
545                tag: tags::SCHEDULED_PROCEDURE_STEP_ID,
546                substituted: Some(id.clone()),
547            });
548            id
549        }
550    };
551    let sps_id = bounded(
552        tags::SCHEDULED_PROCEDURE_STEP_ID,
553        limits::SH,
554        sps_id,
555        options.length_policy,
556        w,
557    )?;
558    put_str(
559        &mut item,
560        tags::SCHEDULED_PROCEDURE_STEP_ID,
561        VR::SH,
562        &sps_id,
563    );
564
565    // (0040,0010) Scheduled Station Name, SH, Type 2. IHE: IPC-7.
566    match ipc_field(7, 1) {
567        Some(v) => put_str(
568            &mut item,
569            tags::SCHEDULED_STATION_NAME,
570            VR::SH,
571            &bounded(
572                tags::SCHEDULED_STATION_NAME,
573                limits::SH,
574                v,
575                options.length_policy,
576                w,
577            )?,
578        ),
579        None => put_empty(&mut item, tags::SCHEDULED_STATION_NAME, VR::SH),
580    }
581    // (0040,0011) SPS Location, SH, Type 2. IHE: IPC-8.
582    match ipc_field(8, 1) {
583        Some(v) => put_str(
584            &mut item,
585            tags::SCHEDULED_PROCEDURE_STEP_LOCATION,
586            VR::SH,
587            &bounded(
588                tags::SCHEDULED_PROCEDURE_STEP_LOCATION,
589                limits::SH,
590                v,
591                options.length_policy,
592                w,
593            )?,
594        ),
595        None => put_empty(&mut item, tags::SCHEDULED_PROCEDURE_STEP_LOCATION, VR::SH),
596    }
597    // (0040,0020) SPS Status, CS, Type 3. Unsourced: SCHEDULED for a new
598    // order (ORC-1 NW), omitted otherwise.
599    if field(msg, "ORC-1")
600        .map(|s| s.to_ascii_uppercase())
601        .as_deref()
602        == Some("NW")
603    {
604        put_str(
605            &mut item,
606            tags::SCHEDULED_PROCEDURE_STEP_STATUS,
607            VR::CS,
608            "SCHEDULED",
609        );
610    }
611    Ok(item)
612}
613
614/// A DICOM modality code from IPC-5 or OBR-24: DICOM codes (PS3.3 C.7.3.1.1.1)
615/// pass through, the HL7 table 0074 codes with a single DICOM meaning are
616/// translated, everything else is `None`.
617fn modality_code(value: &str, w: &mut Vec<Warning>) -> Option<String> {
618    const DICOM: &[&str] = &[
619        "AR", "AU", "BDUS", "BI", "BMD", "CR", "CT", "DG", "DX", "ECG", "EPS", "ES", "GM", "HC",
620        "HD", "IO", "IVOCT", "IVUS", "KER", "LEN", "LS", "MG", "MR", "NM", "OAM", "OCT", "OP",
621        "OPM", "OPT", "OPV", "OSS", "OT", "PT", "PX", "REG", "RF", "RG", "RTIMAGE", "SM", "SR",
622        "TG", "US", "VA", "XA", "XC",
623    ];
624    // HL7 table 0074 codes that name exactly one DICOM modality. RAD
625    // (radiology) and others are ambiguous and are refused.
626    const TABLE_0074: &[(&str, &str)] = &[
627        ("MRI", "MR"),
628        ("NMR", "MR"),
629        ("NMS", "NM"),
630        ("RUS", "US"),
631        ("VUS", "US"),
632        ("XRC", "XA"),
633        ("OTH", "OT"),
634    ];
635    let v = value.trim().to_ascii_uppercase();
636    if DICOM.contains(&v.as_str()) {
637        return Some(v);
638    }
639    let to = TABLE_0074.iter().find(|(from, _)| *from == v)?.1;
640    w.push(Warning::ModalityTranslated {
641        from: value.trim().to_string(),
642        to: to.to_string(),
643    });
644    Some(to.to_string())
645}
646
647// ---------------------------------------------------------------------------
648// Visit modules
649// ---------------------------------------------------------------------------
650
651/// IHE: (0038,0010) Admission ID from PV1-19.1, (0038,0300) Current Patient
652/// Location from PV1-3 (PL: point of care^room^bed). Both Type 2.
653fn visit(ds: &mut InMemDicomObject, msg: &Message) {
654    match field(msg, "PV1-19.1") {
655        Some(v) => put_str(
656            ds,
657            tags::ADMISSION_ID,
658            VR::LO,
659            &v.chars().take(limits::LO).collect::<String>(),
660        ),
661        None => put_empty(ds, tags::ADMISSION_ID, VR::LO),
662    }
663    let location: Vec<String> = ["PV1-3.1", "PV1-3.2", "PV1-3.3"]
664        .iter()
665        .filter_map(|p| field(msg, p))
666        .collect();
667    if location.is_empty() {
668        put_empty(ds, tags::CURRENT_PATIENT_LOCATION, VR::LO);
669    } else {
670        let joined = location.join(" ");
671        put_str(
672            ds,
673            tags::CURRENT_PATIENT_LOCATION,
674            VR::LO,
675            &joined.chars().take(limits::LO).collect::<String>(),
676        );
677    }
678}
679
680// ---------------------------------------------------------------------------
681// Helpers
682// ---------------------------------------------------------------------------
683
684/// A CWE-shaped field (`code^meaning^scheme`) as a code sequence item, only
685/// when the scheme is present.
686fn code_item(
687    msg: &Message,
688    path: &str,
689    options: &Options,
690    w: &mut Vec<Warning>,
691) -> Result<Option<InMemDicomObject>, MwlError> {
692    let code = field(msg, &format!("{path}.1"));
693    let scheme = field(msg, &format!("{path}.3"));
694    let (Some(code), Some(scheme)) = (code, scheme) else {
695        return Ok(None);
696    };
697    let mut item = InMemDicomObject::new_empty();
698    put_str(
699        &mut item,
700        tags::CODE_VALUE,
701        VR::SH,
702        &bounded(tags::CODE_VALUE, limits::SH, code, options.length_policy, w)?,
703    );
704    put_str(
705        &mut item,
706        tags::CODING_SCHEME_DESIGNATOR,
707        VR::SH,
708        &bounded(
709            tags::CODING_SCHEME_DESIGNATOR,
710            limits::SH,
711            scheme,
712            options.length_policy,
713            w,
714        )?,
715    );
716    if let Some(meaning) = field(msg, &format!("{path}.2")) {
717        put_str(
718            &mut item,
719            tags::CODE_MEANING,
720            VR::LO,
721            &bounded(
722                tags::CODE_MEANING,
723                limits::LO,
724                meaning,
725                options.length_policy,
726                w,
727            )?,
728        );
729    }
730    Ok(Some(item))
731}
732
733/// A decoded, trimmed, non-empty field or component.
734fn field(msg: &Message, path: &str) -> Option<String> {
735    msg.get_decoded(path)
736        .map(|v| v.trim().to_string())
737        .filter(|v| !v.is_empty())
738}
739
740/// `YYYYMMDD` from an HL7 DTM, validated.
741pub fn hl7_date(dtm: &str) -> Option<String> {
742    let d: String = dtm.trim().chars().take(8).collect();
743    if d.len() != 8 || !d.chars().all(|c| c.is_ascii_digit()) {
744        return None;
745    }
746    let month: u8 = d[4..6].parse().ok()?;
747    let day: u8 = d[6..8].parse().ok()?;
748    ((1..=12).contains(&month) && (1..=31).contains(&day)).then_some(d)
749}
750
751/// `HHMMSS` from an HL7 DTM (`YYYYMMDDHH[MM[SS]]…`), seconds and minutes
752/// padded with zeros when absent, `None` when there is no hour.
753pub fn hl7_time(dtm: &str) -> Option<String> {
754    let s = dtm.trim();
755    let end = s.find(['+', '-', '.']).unwrap_or(s.len());
756    let digits = &s[..end];
757    if digits.len() < 10 || !digits.chars().all(|c| c.is_ascii_digit()) {
758        return None;
759    }
760    let t = &digits[8..digits.len().min(14)];
761    let mut t = t.to_string();
762    while t.len() < 6 {
763        t.push('0');
764    }
765    let h: u8 = t[..2].parse().ok()?;
766    let m: u8 = t[2..4].parse().ok()?;
767    let sec: u8 = t[4..6].parse().ok()?;
768    (h < 24 && m < 60 && sec < 61).then_some(t)
769}
770
771fn put_str(ds: &mut InMemDicomObject, tag: Tag, vr: VR, value: &str) {
772    ds.put(DataElement::new(tag, vr, PrimitiveValue::from(value)));
773}
774
775fn put_empty(ds: &mut InMemDicomObject, tag: Tag, vr: VR) {
776    ds.put(DataElement::new(tag, vr, PrimitiveValue::Empty));
777}
778
779fn put_seq(ds: &mut InMemDicomObject, tag: Tag, items: Vec<InMemDicomObject>) {
780    ds.put(DataElement::new(tag, VR::SQ, DataSetSequence::from(items)));
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn dates_and_times() {
789        assert_eq!(hl7_date("20260905113000").as_deref(), Some("20260905"));
790        assert_eq!(hl7_date("2026090"), None);
791        assert_eq!(hl7_date("20261305"), None);
792        assert_eq!(hl7_time("20260905113000").as_deref(), Some("113000"));
793        assert_eq!(hl7_time("202609051130").as_deref(), Some("113000"));
794        assert_eq!(hl7_time("2026090511").as_deref(), Some("110000"));
795        assert_eq!(hl7_time("20260905113000+0200").as_deref(), Some("113000"));
796        assert_eq!(hl7_time("20260905"), None);
797        assert_eq!(hl7_time("2026090525"), None);
798    }
799
800    #[test]
801    fn modality_codes() {
802        let mut w = Vec::new();
803        assert_eq!(modality_code("MR", &mut w).as_deref(), Some("MR"));
804        assert_eq!(modality_code("ct", &mut w).as_deref(), Some("CT"));
805        assert!(w.is_empty());
806        assert_eq!(modality_code("MRI", &mut w).as_deref(), Some("MR"));
807        assert_eq!(w.len(), 1);
808        assert_eq!(
809            modality_code("RAD", &mut w),
810            None,
811            "ambiguous table 0074 code"
812        );
813        assert_eq!(modality_code("", &mut w), None);
814    }
815}