Skip to main content

mwlkit/
uid.rs

1//! Study Instance UID resolution and generation, the one value this crate
2//! is allowed to invent.
3//!
4//! Resolution follows `hl7kit`: `IPC-3.1`, then `ZDS-1.1`, then the OBX
5//! carrying DCM 110180. When all are absent, [`UidPolicy`] decides.
6//!
7//! Generated UIDs use the `2.25.` root followed by a 128-bit UUID written
8//! as a decimal integer (DICOM PS3.5 B.2, "UUID derived UID"). That form
9//! needs no registered root, is at most 5 + 39 = 44 characters and so fits
10//! the 64-character UI limit. No made-up root, no real vendor's root.
11
12use crate::{GeneratedFrom as From_, MwlError, UidPolicy, Warning};
13use hl7kit::order::{Order, StudyUidSource};
14
15/// Where the Study Instance UID came from.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum StudyUidOrigin {
18    /// Carried by the order; the RIS knows it.
19    FromOrder(StudyUidSource),
20    /// Generated here; the RIS does not know it.
21    Generated(GeneratedFrom),
22}
23
24/// What a generated UID was derived from.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum GeneratedFrom {
27    /// Name-based, from OBR-19 / IPC-2 (dcm4che's first choice).
28    RequestedProcedureId,
29    /// Name-based, from the accession number (dcm4che's second choice).
30    AccessionNumber,
31    /// From caller-supplied random bits.
32    Random,
33}
34
35impl std::fmt::Display for GeneratedFrom {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            GeneratedFrom::RequestedProcedureId => write!(f, "from the Requested Procedure ID"),
39            GeneratedFrom::AccessionNumber => write!(f, "from the Accession Number"),
40            GeneratedFrom::Random => write!(f, "at random"),
41        }
42    }
43}
44
45/// Namespace mixed into every name-based UID this crate derives, so a UID
46/// derived here never collides with one derived elsewhere from the same
47/// string.
48pub const NAMESPACE: &str = "mwlkit.study-instance-uid";
49
50/// `2.25.<uuid as decimal>` from 128 bits.
51pub fn uuid_uid(bits: u128) -> String {
52    format!("2.25.{bits}")
53}
54
55/// A deterministic UUID-derived UID for `name` under `namespace`: RFC 9562
56/// version 8, built from two FNV-1a 64-bit hashes with different offsets.
57pub fn name_based_uid(namespace: &str, name: &str) -> String {
58    let text = format!("{namespace}|{name}");
59    let hi = fnv1a(text.as_bytes(), 0xcbf2_9ce4_8422_2325);
60    let lo = fnv1a(text.as_bytes(), 0x84222325_cbf29ce4 ^ 0x5bd1_e995_9a2b_f347);
61    let hi = (hi & 0xffff_ffff_ffff_0fff) | 0x0000_0000_0000_8000; // version 8
62    let lo = (lo & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; // variant 10
63    uuid_uid((u128::from(hi) << 64) | u128::from(lo))
64}
65
66fn fnv1a(bytes: &[u8], offset: u64) -> u64 {
67    let mut h = offset;
68    for &b in bytes {
69        h ^= u64::from(b);
70        h = h.wrapping_mul(0x0000_0100_0000_01b3);
71    }
72    h
73}
74
75/// The UID for the order, its origin, and a warning when it was generated.
76pub fn resolve(
77    order: &Order,
78    policy: &UidPolicy,
79) -> Result<(String, StudyUidOrigin, Option<Warning>), MwlError> {
80    if let (Some(uid), Some(source)) = (&order.study_uid, order.study_uid_source) {
81        return Ok((uid.clone(), StudyUidOrigin::FromOrder(source), None));
82    }
83    let (uid, from) = match policy {
84        UidPolicy::Refuse => return Err(MwlError::NoStudyUid),
85        UidPolicy::Random(bits) => (uuid_uid(*bits), From_::Random),
86        // dcm4che derives a name-based UID from the Requested Procedure ID
87        // when present, else from the Accession Number.
88        UidPolicy::Dcm4cheStyle => match (&order.procedure_id, &order.accession) {
89            (Some(rp), _) => (
90                name_based_uid(NAMESPACE, &format!("rp|{rp}")),
91                From_::RequestedProcedureId,
92            ),
93            (None, Some(acc)) => (
94                name_based_uid(NAMESPACE, &format!("acc|{acc}")),
95                From_::AccessionNumber,
96            ),
97            (None, None) => return Err(MwlError::NoStudyUid),
98        },
99    };
100    Ok((
101        uid,
102        StudyUidOrigin::Generated(from),
103        Some(Warning::StudyUidGenerated(from)),
104    ))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn generated_uids_are_2_25_decimal_and_short_enough() {
113        let u = name_based_uid(NAMESPACE, "rp|RP-1");
114        assert!(u.starts_with("2.25."));
115        assert!(u[5..].chars().all(|c| c.is_ascii_digit()));
116        assert!(u.len() <= 44, "{u} is {} chars", u.len());
117        assert_eq!(u, name_based_uid(NAMESPACE, "rp|RP-1"), "deterministic");
118        assert_ne!(u, name_based_uid(NAMESPACE, "rp|RP-2"));
119        assert_ne!(u, name_based_uid("other", "rp|RP-1"));
120        assert_eq!(uuid_uid(0), "2.25.0");
121        assert_eq!(uuid_uid(u128::MAX).len(), 44);
122    }
123
124    #[test]
125    fn policies() {
126        let mut order = Order::default();
127        assert_eq!(
128            resolve(&order, &UidPolicy::Refuse),
129            Err(MwlError::NoStudyUid)
130        );
131        assert_eq!(
132            resolve(&order, &UidPolicy::Dcm4cheStyle),
133            Err(MwlError::NoStudyUid)
134        );
135        let (u, origin, w) = resolve(&order, &UidPolicy::Random(7)).unwrap();
136        assert_eq!(u, "2.25.7");
137        assert_eq!(origin, StudyUidOrigin::Generated(GeneratedFrom::Random));
138        assert_eq!(w, Some(Warning::StudyUidGenerated(GeneratedFrom::Random)));
139
140        order.accession = Some("ACC-1".into());
141        let (a, origin, _) = resolve(&order, &UidPolicy::Dcm4cheStyle).unwrap();
142        assert_eq!(
143            origin,
144            StudyUidOrigin::Generated(GeneratedFrom::AccessionNumber)
145        );
146        order.procedure_id = Some("RP-1".into());
147        let (r, origin, _) = resolve(&order, &UidPolicy::Dcm4cheStyle).unwrap();
148        assert_eq!(
149            origin,
150            StudyUidOrigin::Generated(GeneratedFrom::RequestedProcedureId)
151        );
152        assert_ne!(a, r);
153
154        order.study_uid = Some("1.2.3".into());
155        order.study_uid_source = Some(StudyUidSource::Ipc3);
156        let (u, origin, w) = resolve(&order, &UidPolicy::Refuse).unwrap();
157        assert_eq!(u, "1.2.3");
158        assert_eq!(origin, StudyUidOrigin::FromOrder(StudyUidSource::Ipc3));
159        assert_eq!(w, None);
160    }
161}