Skip to main content

dicom_toolkit_core/
uid.rs

1//! DICOM Unique Identifier (UID) handling.
2//!
3//! Ports the UID generation and well-known UID constant functionality from
4//! DCMTK's `dcuid.h` / `ofuuid.h`.
5
6use crate::error::{DcmError, DcmResult};
7use std::fmt;
8
9/// Maximum length of a DICOM UID (64 characters per PS3.5 §9.1).
10pub const MAX_UID_LENGTH: usize = 64;
11
12/// A validated DICOM UID.
13///
14/// UIDs consist of dot-separated numeric components (digits and dots only),
15/// with a maximum length of 64 characters.
16#[derive(Clone, PartialEq, Eq, Hash)]
17pub struct Uid(String);
18
19impl Uid {
20    /// Creates a new `Uid` from a string, validating the format.
21    pub fn new(s: impl Into<String>) -> DcmResult<Self> {
22        let s = s.into();
23        Self::validate(&s)?;
24        Ok(Self(s))
25    }
26
27    /// Returns `true` if `s` is a syntactically valid DICOM UID.
28    pub fn is_valid(s: &str) -> bool {
29        Self::validate(s).is_ok()
30    }
31
32    /// Creates a `Uid` without validation. Use only for known-valid UIDs
33    /// (e.g., compile-time constants).
34    ///
35    /// # Safety (logical)
36    /// The caller must ensure the string is a valid DICOM UID.
37    pub fn from_static(s: &'static str) -> Self {
38        debug_assert!(Self::validate(s).is_ok(), "invalid static UID: {s}");
39        Self(s.to_string())
40    }
41
42    /// Internal helper that creates a Uid from a known-valid string at runtime.
43    #[allow(dead_code)]
44    pub(crate) fn from_valid(s: String) -> Self {
45        Self(s)
46    }
47
48    /// Returns the UID as a string slice.
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52
53    /// Generates a new unique UID under the given `root` prefix.
54    ///
55    /// The generated UID has the form `{root}.{uuid_based_suffix}` and is
56    /// guaranteed to be ≤ 64 characters.
57    pub fn generate(root: &str) -> DcmResult<Self> {
58        let uuid_val = uuid::Uuid::new_v4();
59        // Convert UUID to numeric form (remove hyphens, treat as decimal-ish)
60        let uuid_bytes = uuid_val.as_bytes();
61        let num = u128::from_be_bytes(*uuid_bytes);
62        let suffix = num.to_string();
63
64        let uid_str = format!("{root}.{suffix}");
65        if uid_str.len() > MAX_UID_LENGTH {
66            // Truncate suffix to fit
67            let max_suffix = MAX_UID_LENGTH - root.len() - 1;
68            let uid_str = format!("{root}.{}", &suffix[..max_suffix]);
69            return Self::new(uid_str);
70        }
71        Self::new(uid_str)
72    }
73
74    /// Validates that a string is a legal DICOM UID.
75    fn validate(s: &str) -> DcmResult<()> {
76        if s.is_empty() {
77            return Err(DcmError::InvalidUid {
78                reason: "UID must not be empty".into(),
79            });
80        }
81        if s.len() > MAX_UID_LENGTH {
82            return Err(DcmError::InvalidUid {
83                reason: format!(
84                    "UID exceeds maximum length of {MAX_UID_LENGTH}: got {}",
85                    s.len()
86                ),
87            });
88        }
89        if s.starts_with('.') || s.ends_with('.') {
90            return Err(DcmError::InvalidUid {
91                reason: "UID must not start or end with a dot".into(),
92            });
93        }
94        if s.contains("..") {
95            return Err(DcmError::InvalidUid {
96                reason: "UID must not contain consecutive dots".into(),
97            });
98        }
99        for ch in s.chars() {
100            if !ch.is_ascii_digit() && ch != '.' {
101                return Err(DcmError::InvalidUid {
102                    reason: format!("UID contains invalid character: '{ch}'"),
103                });
104            }
105        }
106        // Each component must not have a leading zero (except "0" itself)
107        for component in s.split('.') {
108            if component.is_empty() {
109                return Err(DcmError::InvalidUid {
110                    reason: "UID contains an empty component".into(),
111                });
112            }
113            if component.len() > 1 && component.starts_with('0') {
114                return Err(DcmError::InvalidUid {
115                    reason: format!("UID component has leading zero: '{component}'"),
116                });
117            }
118        }
119        Ok(())
120    }
121}
122
123impl fmt::Debug for Uid {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(f, "Uid(\"{}\")", self.0)
126    }
127}
128
129impl fmt::Display for Uid {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.write_str(&self.0)
132    }
133}
134
135impl AsRef<str> for Uid {
136    fn as_ref(&self) -> &str {
137        &self.0
138    }
139}
140
141impl TryFrom<String> for Uid {
142    type Error = DcmError;
143    fn try_from(s: String) -> DcmResult<Self> {
144        Self::new(s)
145    }
146}
147
148impl TryFrom<&str> for Uid {
149    type Error = DcmError;
150    fn try_from(s: &str) -> DcmResult<Self> {
151        Self::new(s)
152    }
153}
154
155// ── Well-Known DICOM UIDs ────────────────────────────────────────────────
156
157/// Well-known DICOM SOP Class UIDs.
158///
159/// Ported from DCMTK's `dcuid.h`.
160pub mod sop_class {
161    /// Verification SOP Class (C-ECHO).
162    pub const VERIFICATION: &str = "1.2.840.10008.1.1";
163
164    // ── Storage SOP Classes ──────────────────────────────────────────
165    pub const CT_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.2";
166    pub const ENHANCED_CT_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.2.1";
167    pub const MR_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.4";
168    pub const ENHANCED_MR_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.4.1";
169    pub const ULTRASOUND_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.6.1";
170    pub const SECONDARY_CAPTURE_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.7";
171    pub const DIGITAL_XRAY_IMAGE_STORAGE_FOR_PRESENTATION: &str = "1.2.840.10008.5.1.4.1.1.1.1";
172    pub const DIGITAL_XRAY_IMAGE_STORAGE_FOR_PROCESSING: &str = "1.2.840.10008.5.1.4.1.1.1.1.1";
173    pub const DIGITAL_MAMMOGRAPHY_IMAGE_STORAGE_FOR_PRESENTATION: &str =
174        "1.2.840.10008.5.1.4.1.1.1.2";
175    pub const CR_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.1";
176    pub const NM_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.20";
177    pub const PET_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.128";
178    pub const RT_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.481.1";
179    pub const RT_DOSE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.481.2";
180    pub const RT_STRUCTURE_SET_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.481.3";
181    pub const RT_PLAN_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.481.5";
182    pub const XA_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.12.1";
183    pub const VL_PHOTOGRAPHIC_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.77.1.4";
184    pub const VIDEO_ENDOSCOPIC_IMAGE_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.77.1.1.1";
185    pub const ENCAPSULATED_PDF_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.104.1";
186    pub const ENCAPSULATED_CDA_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.104.2";
187    pub const BASIC_TEXT_SR_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.88.11";
188    pub const ENHANCED_SR_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.88.22";
189    pub const COMPREHENSIVE_SR_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.88.33";
190    pub const SEGMENTATION_STORAGE: &str = "1.2.840.10008.5.1.4.1.1.66.4";
191
192    // ── Query/Retrieve SOP Classes ───────────────────────────────────
193    pub const PATIENT_ROOT_QR_FIND: &str = "1.2.840.10008.5.1.4.1.2.1.1";
194    pub const PATIENT_ROOT_QR_MOVE: &str = "1.2.840.10008.5.1.4.1.2.1.2";
195    pub const PATIENT_ROOT_QR_GET: &str = "1.2.840.10008.5.1.4.1.2.1.3";
196    pub const STUDY_ROOT_QR_FIND: &str = "1.2.840.10008.5.1.4.1.2.2.1";
197    pub const STUDY_ROOT_QR_MOVE: &str = "1.2.840.10008.5.1.4.1.2.2.2";
198    pub const STUDY_ROOT_QR_GET: &str = "1.2.840.10008.5.1.4.1.2.2.3";
199
200    // ── Worklist SOP Classes ─────────────────────────────────────────
201    pub const MODALITY_WORKLIST_FIND: &str = "1.2.840.10008.5.1.4.31";
202
203    // ── Print Management ─────────────────────────────────────────────
204    pub const BASIC_FILM_SESSION: &str = "1.2.840.10008.5.1.1.1";
205    pub const BASIC_FILM_BOX: &str = "1.2.840.10008.5.1.1.2";
206    pub const BASIC_GRAYSCALE_IMAGE_BOX: &str = "1.2.840.10008.5.1.1.4";
207    pub const BASIC_COLOR_IMAGE_BOX: &str = "1.2.840.10008.5.1.1.4.1";
208    pub const PRINTER: &str = "1.2.840.10008.5.1.1.16";
209
210    // ── Storage Commitment ───────────────────────────────────────────
211    pub const STORAGE_COMMITMENT_PUSH_MODEL: &str = "1.2.840.10008.1.20.1";
212}
213
214/// Well-known DICOM Transfer Syntax UIDs.
215///
216/// Ported from DCMTK's `dcxfer.h`.
217pub mod transfer_syntax {
218    pub const IMPLICIT_VR_LITTLE_ENDIAN: &str = "1.2.840.10008.1.2";
219    pub const EXPLICIT_VR_LITTLE_ENDIAN: &str = "1.2.840.10008.1.2.1";
220    pub const EXPLICIT_VR_BIG_ENDIAN: &str = "1.2.840.10008.1.2.2";
221    pub const DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN: &str = "1.2.840.10008.1.2.1.99";
222
223    // JPEG
224    pub const JPEG_BASELINE: &str = "1.2.840.10008.1.2.4.50";
225    pub const JPEG_EXTENDED: &str = "1.2.840.10008.1.2.4.51";
226    pub const JPEG_LOSSLESS_NON_HIERARCHICAL: &str = "1.2.840.10008.1.2.4.57";
227    pub const JPEG_LOSSLESS_NON_HIERARCHICAL_FIRST_ORDER: &str = "1.2.840.10008.1.2.4.70";
228
229    // JPEG-LS
230    pub const JPEG_LS_LOSSLESS: &str = "1.2.840.10008.1.2.4.80";
231    pub const JPEG_LS_LOSSY: &str = "1.2.840.10008.1.2.4.81";
232
233    // JPEG 2000
234    pub const JPEG_2000_LOSSLESS: &str = "1.2.840.10008.1.2.4.90";
235    pub const JPEG_2000: &str = "1.2.840.10008.1.2.4.91";
236    pub const HIGH_THROUGHPUT_JPEG_2000_LOSSLESS_ONLY: &str = "1.2.840.10008.1.2.4.201";
237    pub const HIGH_THROUGHPUT_JPEG_2000_RPCL_LOSSLESS_ONLY: &str = "1.2.840.10008.1.2.4.202";
238    pub const HIGH_THROUGHPUT_JPEG_2000: &str = "1.2.840.10008.1.2.4.203";
239
240    // RLE
241    pub const RLE_LOSSLESS: &str = "1.2.840.10008.1.2.5";
242
243    // Encapsulated Uncompressed
244    pub const ENCAPSULATED_UNCOMPRESSED: &str = "1.2.840.10008.1.2.1.98";
245}
246
247/// DCMTK-RS implementation class UID root.
248pub const DCMTK_RS_UID_ROOT: &str = "1.2.826.0.1.3680043.8.498";
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn valid_uid() {
256        assert!(Uid::new("1.2.840.10008.1.1").is_ok());
257        assert!(Uid::new("1.2.3").is_ok());
258        assert!(Uid::new("0").is_ok());
259    }
260
261    #[test]
262    fn invalid_uid_empty() {
263        assert!(Uid::new("").is_err());
264    }
265
266    #[test]
267    fn invalid_uid_too_long() {
268        let long = "1.".repeat(33);
269        assert!(Uid::new(&long[..long.len() - 1]).is_err());
270    }
271
272    #[test]
273    fn invalid_uid_leading_dot() {
274        assert!(Uid::new(".1.2.3").is_err());
275    }
276
277    #[test]
278    fn invalid_uid_trailing_dot() {
279        assert!(Uid::new("1.2.3.").is_err());
280    }
281
282    #[test]
283    fn invalid_uid_consecutive_dots() {
284        assert!(Uid::new("1.2..3").is_err());
285    }
286
287    #[test]
288    fn invalid_uid_non_numeric() {
289        assert!(Uid::new("1.2.abc").is_err());
290    }
291
292    #[test]
293    fn invalid_uid_leading_zero() {
294        assert!(Uid::new("1.02.3").is_err());
295    }
296
297    #[test]
298    fn from_static_preserves_value() {
299        let uid = Uid::from_static("1.2.840.10008.1.1");
300        assert_eq!(uid.as_str(), "1.2.840.10008.1.1");
301    }
302
303    #[test]
304    fn generate_uid() {
305        let uid = Uid::generate("1.2.3").unwrap();
306        assert!(uid.as_str().starts_with("1.2.3."));
307        assert!(uid.as_str().len() <= MAX_UID_LENGTH);
308    }
309
310    #[test]
311    fn uid_display() {
312        let uid = Uid::new("1.2.840.10008.1.1").unwrap();
313        assert_eq!(uid.to_string(), "1.2.840.10008.1.1");
314    }
315}