dicom_anonymization/config/
mod.rs

1pub mod builder;
2pub(crate) mod tag_action_map;
3pub mod uid_root;
4
5use crate::actions::Action;
6use crate::hasher::{blake3_hash_fn, HashFn};
7use crate::Tag;
8use serde::{Deserialize, Serialize};
9use tag_action_map::TagActionMap;
10use thiserror::Error;
11use uid_root::{UidRoot, UidRootError};
12
13const DEIDENTIFIER: &str = "CARECODERS.IO";
14
15#[derive(Error, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
16pub enum ConfigError {
17    #[error("invalid UID root: {0}")]
18    InvalidUidRoot(String),
19
20    #[error("invalid hash length: {0}")]
21    InvalidHashLength(String),
22}
23
24impl From<UidRootError> for ConfigError {
25    fn from(err: UidRootError) -> Self {
26        ConfigError::InvalidUidRoot(err.0)
27    }
28}
29
30pub fn default_hash_fn() -> HashFn {
31    blake3_hash_fn
32}
33
34/// Configuration for DICOM de-identification.
35///
36/// This struct contains all the settings that control how DICOM objects will be de-identified, including
37/// UID handling, tag-specific actions, and policies for special tag groups.
38///
39/// # Fields
40///
41/// * `hash_fn` - The hash function used for all operations requiring hashing
42/// * `uid_root` - The [`UidRoot`] to use as prefix when generating new UIDs during de-identification
43/// * `remove_private_tags` - Policy determining whether to keep or remove private DICOM tags
44/// * `remove_curves` - Policy determining whether to keep or remove curve data (groups `0x5000-0x50FF`)
45/// * `remove_overlays` - Policy determining whether to keep or remove overlay data (groups `0x6000-0x60FF`)
46/// * `tag_actions` - Mapping of specific DICOM tags to their corresponding de-identification actions
47#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
48pub struct Config {
49    #[serde(skip, default = "default_hash_fn")]
50    hash_fn: HashFn,
51
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    uid_root: Option<UidRoot>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    remove_private_tags: Option<bool>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    remove_curves: Option<bool>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    remove_overlays: Option<bool>,
60
61    #[serde(
62        default = "TagActionMap::default",
63        skip_serializing_if = "TagActionMap::is_empty"
64    )]
65    tag_actions: TagActionMap,
66}
67
68impl Config {
69    fn new(
70        hash_fn: HashFn,
71        uid_root: Option<UidRoot>,
72        remove_private_tags: Option<bool>,
73        remove_curves: Option<bool>,
74        remove_overlays: Option<bool>,
75    ) -> Self {
76        Self {
77            hash_fn,
78            uid_root,
79            remove_private_tags,
80            remove_curves,
81            remove_overlays,
82            tag_actions: TagActionMap::new(),
83        }
84    }
85}
86
87impl Default for Config {
88    fn default() -> Self {
89        Self::new(blake3_hash_fn, None, None, None, None)
90    }
91}
92
93pub(crate) fn is_private_tag(tag: &Tag) -> bool {
94    // tags with odd group numbers are private tags
95    tag.group() % 2 != 0
96}
97
98pub(crate) fn is_curve_tag(tag: &Tag) -> bool {
99    (tag.group() & 0xFF00) == 0x5000
100}
101
102pub(crate) fn is_overlay_tag(tag: &Tag) -> bool {
103    (tag.group() & 0xFF00) == 0x6000
104}
105
106impl Config {
107    pub fn get_hash_fn(&self) -> HashFn {
108        self.hash_fn
109    }
110
111    pub fn get_uid_root(&self) -> &Option<UidRoot> {
112        &self.uid_root
113    }
114
115    /// Returns the appropriate [`Action`] to take for a given DICOM tag.
116    ///
117    /// This function determines what action should be taken for a specific tag during de-identification
118    /// by checking:
119    /// 1. If the tag has an explicit action defined in `tag_actions`
120    /// 2. Whether the tag should be removed based on the configuration for tag groups (i.e. private tags, curves, overlays)
121    ///
122    /// # Priority Rules
123    /// - If the tag has an explicit action configured of `Action::None` but should be removed based on point 2., returns `Action::Remove`
124    /// - If the tag has any other explicit action configured, returns that action
125    /// - If the tag has no explicit action configured but should be removed based on point 2., returns `Action::Remove`
126    /// - If the tag has no explicit action configured and shouldn't be removed based on point 2., returns `Action::Keep`
127    ///
128    /// # Arguments
129    ///
130    /// * `tag` - Reference to the DICOM tag to get the action for
131    ///
132    /// # Returns
133    ///
134    /// A reference to the appropriate [`Action`] to take for the given tag
135    pub fn get_action(&self, tag: &Tag) -> &Action {
136        match self.tag_actions.get(tag) {
137            Some(action) if action == &Action::None && self.should_be_removed(tag) => {
138                &Action::Remove
139            }
140            Some(action) => action,
141            None if self.should_be_removed(tag) => &Action::Remove,
142            None => &Action::Keep,
143        }
144    }
145
146    fn should_be_removed(&self, tag: &Tag) -> bool {
147        (self.remove_private_tags.unwrap_or(false) && is_private_tag(tag))
148            || (self.remove_curves.unwrap_or(false) && is_curve_tag(tag))
149            || (self.remove_overlays.unwrap_or(false) && is_overlay_tag(tag))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    use crate::tags;
158
159    use builder::ConfigBuilder;
160    use uid_root::UidRoot;
161
162    #[test]
163    fn test_config_builder() {
164        let config = ConfigBuilder::new()
165            .tag_action(tags::PATIENT_NAME, Action::Empty)
166            .build();
167        let tag_action = config.get_action(&tags::PATIENT_NAME);
168        assert_eq!(tag_action, &Action::Empty);
169
170        // tags without explicit action should be kept by default
171        let tag_action = config.get_action(&tags::PATIENT_ID);
172        assert_eq!(tag_action, &Action::Keep);
173    }
174
175    #[test]
176    fn test_is_private_tag() {
177        // private tags
178        assert!(is_private_tag(&Tag::from([1, 0])));
179        assert!(is_private_tag(&Tag::from([13, 12])));
180        assert!(is_private_tag(&Tag::from([33, 33])));
181
182        // non_private tags
183        assert!(!is_private_tag(&tags::ACCESSION_NUMBER));
184        assert!(!is_private_tag(&tags::PATIENT_ID));
185        assert!(!is_private_tag(&tags::PIXEL_DATA));
186    }
187
188    #[test]
189    fn test_keep_private_tag() {
190        let tag = Tag(0x0033, 0x0010);
191        let config = ConfigBuilder::new()
192            .remove_private_tags(true)
193            .tag_action(tag, Action::Keep)
194            .build();
195
196        // explicitly kept private tags should be kept
197        let tag_action = config.get_action(&tag);
198        assert_eq!(tag_action, &Action::Keep);
199        // any other private tag should be removed
200        assert_eq!(config.get_action(&Tag(0x0033, 0x1010)), &Action::Remove);
201        // any other non-private tag should be kept
202        assert_eq!(config.get_action(&tags::PATIENT_ID), &Action::Keep);
203    }
204
205    #[test]
206    fn test_remove_private_tag() {
207        let tag = Tag(0x0033, 0x0010);
208        let config = ConfigBuilder::new()
209            .remove_private_tags(true)
210            .tag_action(tag, Action::None)
211            .build();
212        let tag_action = config.get_action(&tag);
213        assert_eq!(tag_action, &Action::Remove);
214        assert_eq!(config.get_action(&Tag(0x0033, 0x1010)), &Action::Remove);
215        // any other non-private tag should be kept
216        assert_eq!(config.get_action(&tags::PATIENT_ID), &Action::Keep);
217    }
218
219    #[test]
220    fn test_is_curve_tag() {
221        // curve tags
222        assert!(is_curve_tag(&Tag::from([0x5000, 0])));
223        assert!(is_curve_tag(&Tag::from([0x5010, 0x0011])));
224        assert!(is_curve_tag(&Tag::from([0x50FF, 0x0100])));
225
226        // non-curve tags
227        assert!(!is_curve_tag(&Tag::from([0x5100, 0])));
228        assert!(!is_curve_tag(&Tag::from([0x6000, 0])));
229    }
230
231    #[test]
232    fn test_keep_curve_tag() {
233        let tag = Tag(0x5010, 0x0011);
234        let config = ConfigBuilder::new()
235            .remove_curves(true)
236            .tag_action(tag, Action::Keep)
237            .build();
238
239        // explicitly kept curve tags should be kept
240        let tag_action = config.get_action(&tag);
241        assert_eq!(tag_action, &Action::Keep);
242        // any other curve tags should be removed
243        assert_eq!(config.get_action(&Tag(0x50FF, 0x0100)), &Action::Remove);
244        // any other non-curve tag should be kept
245        assert_eq!(config.get_action(&tags::PATIENT_ID), &Action::Keep);
246    }
247
248    #[test]
249    fn test_remove_curve_tag() {
250        let tag = Tag(0x5010, 0x0011);
251        let config = ConfigBuilder::new()
252            .remove_curves(true)
253            .tag_action(tag, Action::None)
254            .build();
255        let tag_action = config.get_action(&tag);
256        assert_eq!(tag_action, &Action::Remove);
257        assert_eq!(config.get_action(&Tag(0x50FF, 0x0100)), &Action::Remove);
258        // any other non-curve tag should be kept
259        assert_eq!(config.get_action(&tags::PATIENT_ID), &Action::Keep);
260    }
261
262    #[test]
263    fn test_is_overlay_tag() {
264        // overlay tags
265        assert!(is_overlay_tag(&Tag::from([0x6000, 0])));
266        assert!(is_overlay_tag(&Tag::from([0x6010, 0x0011])));
267        assert!(is_overlay_tag(&Tag::from([0x60FF, 0x0100])));
268
269        // non-overlay tags
270        assert!(!is_overlay_tag(&Tag::from([0x6100, 0])));
271        assert!(!is_overlay_tag(&Tag::from([0x5000, 0])));
272    }
273
274    #[test]
275    fn test_keep_overlay_tag() {
276        let tag = Tag(0x6010, 0x0011);
277        let config = ConfigBuilder::new()
278            .remove_overlays(true)
279            .tag_action(tag, Action::Keep)
280            .build();
281
282        // explicitly kept overlay tags should be kept
283        let tag_action = config.get_action(&tag);
284        assert_eq!(tag_action, &Action::Keep);
285        // any other overlay tags should be removed
286        assert_eq!(config.get_action(&Tag(0x60FF, 0x0100)), &Action::Remove);
287        // any other non-overlay tag should be kept
288        assert_eq!(config.get_action(&tags::PATIENT_ID), &Action::Keep);
289    }
290
291    #[test]
292    fn test_remove_overlay_tag() {
293        let tag = Tag(0x6010, 0x0011);
294        let config = ConfigBuilder::new()
295            .remove_overlays(true)
296            .tag_action(tag, Action::None)
297            .build();
298        let tag_action = config.get_action(&tag);
299        assert_eq!(tag_action, &Action::Remove);
300        assert_eq!(config.get_action(&Tag(0x60FF, 0x0100)), &Action::Remove);
301        // any other non-overlay tag should be kept
302        assert_eq!(config.get_action(&tags::PATIENT_ID), &Action::Keep);
303    }
304
305    fn create_sample_tag_actions() -> TagActionMap {
306        let mut map = TagActionMap::new(); // Assuming you have a constructor
307        map.insert(Tag(0x0010, 0x0010), Action::Empty); // Patient Name
308        map.insert(Tag(0x0010, 0x0020), Action::Remove); // Patient ID
309        map.insert(Tag(0x0008, 0x0050), Action::Hash { length: None }); // Accession Number
310        map
311    }
312
313    #[test]
314    fn test_config_serialization() {
315        // Create a sample config
316        let config = Config {
317            uid_root: Some(UidRoot("1.2.826.0.1.3680043.10.188".to_string())),
318            tag_actions: create_sample_tag_actions(),
319            remove_private_tags: Some(true),
320            remove_curves: Some(false),
321            remove_overlays: Some(true),
322            ..Default::default()
323        };
324
325        // Serialize to JSON
326        let json = serde_json::to_string_pretty(&config).unwrap();
327
328        // Basic checks on the JSON string
329        assert!(json.contains(r#""uid_root": "1.2.826.0.1.3680043.10.188"#));
330        assert!(json.contains(r#""remove_private_tags": true"#));
331        assert!(json.contains(r#""remove_curves": false"#));
332        assert!(json.contains(r#""remove_overlays": true"#));
333
334        // Check tag actions serialized correctly
335        assert!(json.contains(r#""00100010""#)); // Patient Name
336        assert!(json.contains(r#""action": "empty""#));
337        assert!(json.contains(r#""00100020""#)); // Patient ID
338        assert!(json.contains(r#""action": "remove""#));
339        assert!(json.contains(r#""00080050""#)); // Accession Number
340        assert!(json.contains(r#""action": "hash""#));
341    }
342
343    #[test]
344    fn test_config_deserialization() {
345        // JSON representation of config
346        let json = r#"{
347            "uid_root": "1.2.826.0.1.3680043.10.188",
348            "remove_private_tags": true,
349            "remove_curves": false,
350            "remove_overlays": true,
351            "tag_actions": {
352                "(0010,0010)": {"action": "empty"},
353                "(0010,0020)": {"action": "remove"},
354                "(0008,0050)": {"action": "hash"}
355            }
356        }"#;
357
358        // Deserialize to Config
359        let config: Config = serde_json::from_str(json).unwrap();
360
361        // Check basic fields
362        assert_eq!(config.uid_root.unwrap().0, "1.2.826.0.1.3680043.10.188");
363        assert_eq!(config.remove_private_tags, Some(true));
364        assert_eq!(config.remove_curves, Some(false));
365        assert_eq!(config.remove_overlays, Some(true));
366
367        // Check tag actions
368        let patient_name = config.tag_actions.get(&Tag(0x0010, 0x0010)).unwrap();
369        match patient_name {
370            Action::Empty => { /* expected */ }
371            _ => panic!("Expected Empty action for Patient Name"),
372        }
373
374        let patient_id = config.tag_actions.get(&Tag(0x0010, 0x0020)).unwrap();
375        match patient_id {
376            Action::Remove => { /* expected */ }
377            _ => panic!("Expected Remove action for Patient ID"),
378        }
379
380        let accession = config.tag_actions.get(&Tag(0x0008, 0x0050)).unwrap();
381        match accession {
382            Action::Hash { length } => {
383                assert_eq!(*length, None);
384            }
385            _ => panic!("Expected Hash action for Accession Number"),
386        }
387    }
388
389    #[test]
390    fn test_config_roundtrip() {
391        // Create original config
392        let original_config = Config {
393            uid_root: Some(UidRoot("1.2.826.0.1.3680043.10.188".to_string())),
394            tag_actions: create_sample_tag_actions(),
395            remove_private_tags: Some(true),
396            remove_curves: Some(false),
397            remove_overlays: Some(true),
398            ..Default::default()
399        };
400
401        // Serialize to JSON and back
402        let json = serde_json::to_string(&original_config).unwrap();
403        let deserialized: Config = serde_json::from_str(&json).unwrap();
404
405        // Compare UID root
406        assert_eq!(
407            original_config.uid_root.unwrap().0,
408            deserialized.uid_root.unwrap().0
409        );
410
411        // Compare boolean flags
412        assert_eq!(
413            original_config.remove_private_tags,
414            deserialized.remove_private_tags
415        );
416        assert_eq!(original_config.remove_curves, deserialized.remove_curves);
417        assert_eq!(
418            original_config.remove_overlays,
419            deserialized.remove_overlays
420        );
421
422        // Compare tag actions
423        let tags_to_check = [
424            Tag(0x0010, 0x0010), // Patient Name
425            Tag(0x0010, 0x0020), // Patient ID
426            Tag(0x0008, 0x0050), // Accession Number
427        ];
428
429        for tag in &tags_to_check {
430            let original_action = original_config.tag_actions.get(tag);
431            let deserialized_action = deserialized.tag_actions.get(tag);
432
433            assert_eq!(
434                original_action, deserialized_action,
435                "Action for tag ({}) didn't roundtrip correctly",
436                tag,
437            );
438        }
439    }
440
441    #[test]
442    fn test_empty_tag_actions() {
443        // Create a config with empty tag actions
444        let empty_map = TagActionMap::new();
445        let config = Config {
446            uid_root: Some(UidRoot("1.2.826.0.1.3680043.10.188".to_string())),
447            tag_actions: empty_map,
448            ..Default::default()
449        };
450
451        // Serialize and deserialize
452        let json = serde_json::to_string(&config).unwrap();
453        let deserialized: Config = serde_json::from_str(&json).unwrap();
454
455        assert_eq!(
456            deserialized.uid_root.unwrap().0,
457            "1.2.826.0.1.3680043.10.188"
458        );
459        assert_eq!(deserialized.remove_private_tags, None);
460        assert_eq!(deserialized.remove_curves, None);
461        assert_eq!(deserialized.remove_overlays, None);
462        assert_eq!(deserialized.tag_actions.len(), 0);
463    }
464
465    #[test]
466    fn test_partial_config_deserialization() {
467        let json = r#"{
468            "uid_root": "1.2.826.0.1.3680043.10.188",
469            "tag_actions": {
470                "(0010,0010)": {"action": "empty"}
471            }
472        }"#;
473
474        let result: Result<Config, _> = serde_json::from_str(json);
475        let config = result.unwrap();
476
477        assert_eq!(config.uid_root.unwrap().0, "1.2.826.0.1.3680043.10.188");
478        assert_eq!(config.remove_private_tags, None);
479        assert_eq!(config.remove_curves, None);
480        assert_eq!(config.remove_overlays, None);
481        assert_eq!(config.tag_actions.len(), 1);
482    }
483
484    #[test]
485    fn test_empty_uid_root_and_tag_actions() {
486        let json = r#"{
487            "uid_root": "",
488            "remove_private_tags": true,
489            "remove_curves": false,
490            "remove_overlays": true,
491            "tag_actions": {}
492        }"#;
493
494        let result: Result<Config, _> = serde_json::from_str(json);
495        let config = result.unwrap();
496
497        assert_eq!(config.uid_root.unwrap().0, "");
498        assert_eq!(config.remove_private_tags, Some(true));
499        assert_eq!(config.remove_curves, Some(false));
500        assert_eq!(config.remove_overlays, Some(true));
501        assert_eq!(config.tag_actions.len(), 0);
502    }
503
504    #[test]
505    fn test_missing_uid_root() {
506        let json = r#"{
507            "remove_private_tags": true,
508            "remove_curves": false,
509            "remove_overlays": true,
510            "tag_actions": {}
511        }"#;
512
513        let result: Result<Config, _> = serde_json::from_str(json);
514        let config = result.unwrap();
515
516        assert_eq!(config.uid_root, None);
517        assert_eq!(config.remove_private_tags, Some(true));
518        assert_eq!(config.remove_curves, Some(false));
519        assert_eq!(config.remove_overlays, Some(true));
520        assert_eq!(config.tag_actions.len(), 0);
521    }
522
523    #[test]
524    fn test_default_remove_fields() {
525        let json = r#"{
526            "uid_root": "9999",
527            "tag_actions": {}
528        }"#;
529
530        let result: Result<Config, _> = serde_json::from_str(json);
531        let config = result.unwrap();
532
533        assert_eq!(config.uid_root, Some(UidRoot("9999".into())));
534        assert_eq!(config.remove_private_tags, None);
535        assert_eq!(config.remove_curves, None);
536        assert_eq!(config.remove_overlays, None);
537        assert_eq!(config.tag_actions.len(), 0);
538    }
539
540    #[test]
541    fn test_only_empty_tag_actions() {
542        let json = r#"{
543            "tag_actions": {}
544        }"#;
545
546        let result: Result<Config, _> = serde_json::from_str(json);
547        let config = result.unwrap();
548
549        assert_eq!(config.uid_root, None);
550        assert_eq!(config.remove_private_tags, None);
551        assert_eq!(config.remove_curves, None);
552        assert_eq!(config.remove_overlays, None);
553        assert_eq!(config.tag_actions.len(), 0);
554    }
555
556    #[test]
557    fn test_empty_json() {
558        let json = r#"{}"#;
559
560        let result: Result<Config, _> = serde_json::from_str(json);
561        let config = result.unwrap();
562
563        assert_eq!(config.uid_root, None);
564        assert_eq!(config.remove_private_tags, None);
565        assert_eq!(config.remove_curves, None);
566        assert_eq!(config.remove_overlays, None);
567        assert_eq!(config.tag_actions.len(), 0);
568    }
569
570    #[test]
571    fn test_malformed_config() {
572        // Invalid tag format
573        let json = r#"{
574            "uid_root": "1.2.826.0.1.3680043.10.188",
575            "remove_private_tags": true,
576            "remove_curves": false,
577            "remove_overlays": true,
578            "tag_actions": {
579                "invalid_tag_format": {"action": "empty"}
580            }
581        }"#;
582
583        let result: Result<Config, _> = serde_json::from_str(json);
584        assert!(result.is_err());
585
586        // Invalid action
587        let json = r#"{
588            "uid_root": "1.2.826.0.1.3680043.10.188",
589            "remove_private_tags": true,
590            "remove_curves": false,
591            "remove_overlays": true,
592            "tag_actions": {
593                "(0010,0010)": {"action": "invalid_action"}
594            },
595        }"#;
596
597        let result: Result<Config, _> = serde_json::from_str(json);
598        assert!(result.is_err());
599    }
600}