wsi_dicom_annotations/annotations/model/
code.rs1use 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}