Skip to main content

wsi_dicom_annotations/annotations/model/
code.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{Error, Result};
4
5const MAX_SHORT_CODE_VALUE_BYTES: usize = 16;
6const MAX_SCHEME_BYTES: usize = 16;
7const MAX_CODE_MEANING_BYTES: usize = 64;
8pub(super) const MAX_LONG_TEXT_BYTES: usize = 10_240;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
12pub enum DicomCodeValueKind {
13    Short,
14    Long,
15    Urn,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct DicomCode {
20    value: String,
21    value_kind: DicomCodeValueKind,
22    scheme: String,
23    coding_scheme_version: Option<String>,
24    meaning: String,
25    context_identifier: Option<String>,
26    context_uid: Option<String>,
27    mapping_resource: Option<String>,
28    mapping_resource_uid: Option<String>,
29    context_group_version: Option<String>,
30    context_group_local_version: Option<String>,
31    context_group_extension: Option<bool>,
32    context_group_extension_creator_uid: Option<String>,
33}
34
35#[derive(Default)]
36pub(crate) struct DicomCodeQualifiers {
37    pub(crate) coding_scheme_version: Option<String>,
38    pub(crate) context_identifier: Option<String>,
39    pub(crate) context_uid: Option<String>,
40    pub(crate) mapping_resource: Option<String>,
41    pub(crate) mapping_resource_uid: Option<String>,
42    pub(crate) context_group_version: Option<String>,
43    pub(crate) context_group_local_version: Option<String>,
44    pub(crate) context_group_extension: Option<bool>,
45    pub(crate) context_group_extension_creator_uid: Option<String>,
46}
47
48impl DicomCode {
49    pub fn new(
50        value: impl Into<String>,
51        scheme: impl Into<String>,
52        meaning: impl Into<String>,
53    ) -> Result<Self> {
54        Self::from_kind(value, DicomCodeValueKind::Short, scheme, meaning)
55    }
56
57    pub fn new_long(
58        value: impl Into<String>,
59        scheme: impl Into<String>,
60        meaning: impl Into<String>,
61    ) -> Result<Self> {
62        Self::from_kind(value, DicomCodeValueKind::Long, scheme, meaning)
63    }
64
65    pub fn new_urn(
66        value: impl Into<String>,
67        scheme: impl Into<String>,
68        meaning: impl Into<String>,
69    ) -> Result<Self> {
70        Self::from_kind(value, DicomCodeValueKind::Urn, scheme, meaning)
71    }
72
73    fn from_kind(
74        value: impl Into<String>,
75        value_kind: DicomCodeValueKind,
76        scheme: impl Into<String>,
77        meaning: impl Into<String>,
78    ) -> Result<Self> {
79        let code = Self {
80            value: value.into(),
81            value_kind,
82            scheme: scheme.into(),
83            coding_scheme_version: None,
84            meaning: meaning.into(),
85            context_identifier: None,
86            context_uid: None,
87            mapping_resource: None,
88            mapping_resource_uid: None,
89            context_group_version: None,
90            context_group_local_version: None,
91            context_group_extension: None,
92            context_group_extension_creator_uid: None,
93        };
94        let max_value_bytes = match value_kind {
95            DicomCodeValueKind::Short => MAX_SHORT_CODE_VALUE_BYTES,
96            DicomCodeValueKind::Long | DicomCodeValueKind::Urn => u32::MAX as usize,
97        };
98        validate_text("code value", &code.value, max_value_bytes)?;
99        if value_kind != DicomCodeValueKind::Urn || !code.scheme.is_empty() {
100            validate_text("coding scheme designator", &code.scheme, MAX_SCHEME_BYTES)?;
101        }
102        validate_text("code meaning", &code.meaning, MAX_CODE_MEANING_BYTES)?;
103        Ok(code)
104    }
105
106    pub fn with_coding_scheme_version(mut self, version: impl Into<String>) -> Result<Self> {
107        if self.scheme.is_empty() {
108            return Err(Error::InvalidInput(
109                "coding scheme version requires a coding scheme designator".into(),
110            ));
111        }
112        let version = version.into();
113        validate_text("coding scheme version", &version, 16)?;
114        self.coding_scheme_version = Some(version);
115        Ok(self)
116    }
117
118    pub fn with_context_identifier(
119        mut self,
120        identifier: impl Into<String>,
121        mapping_resource: impl Into<String>,
122        context_group_version: impl Into<String>,
123    ) -> Result<Self> {
124        let identifier = identifier.into();
125        let mapping_resource = mapping_resource.into();
126        let context_group_version = context_group_version.into();
127        validate_text("context identifier", &identifier, 16)?;
128        validate_text("mapping resource", &mapping_resource, 16)?;
129        validate_text("context group version", &context_group_version, 26)?;
130        self.context_identifier = Some(identifier);
131        self.mapping_resource = Some(mapping_resource);
132        self.context_group_version = Some(context_group_version);
133        Ok(self)
134    }
135
136    pub fn with_context_uid(
137        mut self,
138        context_uid: impl Into<String>,
139        mapping_resource_uid: impl Into<String>,
140    ) -> Result<Self> {
141        let context_uid = context_uid.into();
142        let mapping_resource_uid = mapping_resource_uid.into();
143        validate_dicom_uid("context UID", &context_uid)?;
144        validate_dicom_uid("mapping resource UID", &mapping_resource_uid)?;
145        self.context_uid = Some(context_uid);
146        self.mapping_resource_uid = Some(mapping_resource_uid);
147        Ok(self)
148    }
149
150    pub(crate) fn with_optional_qualifiers(
151        mut self,
152        qualifiers: DicomCodeQualifiers,
153    ) -> Result<Self> {
154        if let Some(value) = qualifiers.coding_scheme_version {
155            self = self.with_coding_scheme_version(value)?;
156        }
157        if let Some(value) = qualifiers.context_identifier {
158            validate_text("context identifier", &value, 16)?;
159            self.context_identifier = Some(value);
160        }
161        if let Some(value) = qualifiers.mapping_resource {
162            validate_text("mapping resource", &value, 16)?;
163            self.mapping_resource = Some(value);
164        }
165        if let Some(value) = qualifiers.context_group_version {
166            validate_text("context group version", &value, 26)?;
167            self.context_group_version = Some(value);
168        }
169        if let Some(value) = qualifiers.context_uid {
170            validate_dicom_uid("context UID", &value)?;
171            self.context_uid = Some(value);
172        }
173        if let Some(value) = qualifiers.mapping_resource_uid {
174            validate_dicom_uid("mapping resource UID", &value)?;
175            self.mapping_resource_uid = Some(value);
176        }
177        if let Some(value) = qualifiers.context_group_local_version {
178            validate_text("context group local version", &value, 26)?;
179            self.context_group_local_version = Some(value);
180        }
181        self.context_group_extension = qualifiers.context_group_extension;
182        if let Some(value) = qualifiers.context_group_extension_creator_uid {
183            validate_dicom_uid("context group extension creator UID", &value)?;
184            self.context_group_extension_creator_uid = Some(value);
185        }
186        if self.context_identifier.is_some() {
187            if self.mapping_resource.is_none() {
188                return Err(Error::InvalidInput(
189                    "context identifier requires a mapping resource".into(),
190                ));
191            }
192            if self.context_group_version.is_none() {
193                return Err(Error::InvalidInput(
194                    "context identifier requires a context group version".into(),
195                ));
196            }
197        }
198        if self.context_group_extension == Some(true) {
199            if self.context_identifier.is_none() {
200                return Err(Error::InvalidInput(
201                    "context group extension requires a context identifier".into(),
202                ));
203            }
204            if self.context_group_local_version.is_none() {
205                return Err(Error::InvalidInput(
206                    "context group extension requires a context group local version".into(),
207                ));
208            }
209            if self.context_group_extension_creator_uid.is_none() {
210                return Err(Error::InvalidInput(
211                    "context group extension requires an extension creator UID".into(),
212                ));
213            }
214        }
215        Ok(self)
216    }
217
218    #[must_use]
219    pub fn value(&self) -> &str {
220        &self.value
221    }
222
223    #[must_use]
224    pub const fn value_kind(&self) -> DicomCodeValueKind {
225        self.value_kind
226    }
227
228    #[must_use]
229    pub fn scheme(&self) -> &str {
230        &self.scheme
231    }
232
233    #[must_use]
234    pub fn coding_scheme_version(&self) -> Option<&str> {
235        self.coding_scheme_version.as_deref()
236    }
237
238    #[must_use]
239    pub fn meaning(&self) -> &str {
240        &self.meaning
241    }
242
243    #[must_use]
244    pub fn context_identifier(&self) -> Option<&str> {
245        self.context_identifier.as_deref()
246    }
247
248    #[must_use]
249    pub fn context_uid(&self) -> Option<&str> {
250        self.context_uid.as_deref()
251    }
252
253    #[must_use]
254    pub fn mapping_resource(&self) -> Option<&str> {
255        self.mapping_resource.as_deref()
256    }
257
258    #[must_use]
259    pub fn mapping_resource_uid(&self) -> Option<&str> {
260        self.mapping_resource_uid.as_deref()
261    }
262
263    #[must_use]
264    pub fn context_group_version(&self) -> Option<&str> {
265        self.context_group_version.as_deref()
266    }
267
268    #[must_use]
269    pub fn context_group_local_version(&self) -> Option<&str> {
270        self.context_group_local_version.as_deref()
271    }
272
273    #[must_use]
274    pub const fn context_group_extension(&self) -> Option<bool> {
275        self.context_group_extension
276    }
277
278    #[must_use]
279    pub fn context_group_extension_creator_uid(&self) -> Option<&str> {
280        self.context_group_extension_creator_uid.as_deref()
281    }
282}
283
284pub(crate) fn validate_text(name: &str, value: &str, max_bytes: usize) -> Result<()> {
285    if value.trim().is_empty() {
286        return Err(Error::InvalidInput(format!("{name} cannot be empty")));
287    }
288    if value.len() > max_bytes {
289        return Err(Error::InvalidInput(format!(
290            "{name} exceeds {max_bytes} bytes"
291        )));
292    }
293    if value.contains(['\\', '\0']) {
294        return Err(Error::InvalidInput(format!(
295            "{name} contains a prohibited DICOM value separator or NUL"
296        )));
297    }
298    Ok(())
299}
300
301pub(crate) fn is_valid_dicom_uid(value: &str) -> bool {
302    !value.is_empty()
303        && value.len() <= 64
304        && value.split('.').all(|component| {
305            !component.is_empty()
306                && component.bytes().all(|byte| byte.is_ascii_digit())
307                && (component.len() == 1 || !component.starts_with('0'))
308        })
309}
310
311fn validate_dicom_uid(name: &str, value: &str) -> Result<()> {
312    if !is_valid_dicom_uid(value) {
313        return Err(Error::InvalidInput(format!(
314            "{name} is not a valid DICOM UID"
315        )));
316    }
317    Ok(())
318}