dicom_anonymization/config/
uid_root.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::str::FromStr;
4use std::sync::OnceLock;
5use thiserror::Error;
6
7static UID_ROOT_REGEX: OnceLock<Regex> = OnceLock::new();
8const UID_ROOT_MAX_LENGTH: usize = 32;
9pub const UID_ROOT_DEFAULT_VALUE: &str = "9999";
10
11/// The [`UidRoot`] struct represents a DICOM UID root that can be used as prefix for
12/// generating new UIDs during de-identification.
13///
14/// The [`UidRoot`] must follow DICOM UID format rules:
15/// - Start with a digit 1-9
16/// - Contain only numbers and dots
17///
18/// It also must not have more than 32 characters.
19///
20/// # Example
21///
22/// ```
23/// use dicom_anonymization::config::uid_root::UidRoot;
24///
25/// // Create a valid UID root
26/// let uid_root = "1.2.840.123".parse::<UidRoot>().unwrap();
27///
28/// // Invalid UID root (not starting with 1-9)
29/// let invalid = "0.1.2".parse::<UidRoot>();
30/// assert!(invalid.is_err());
31/// ```
32#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
33pub struct UidRoot(pub String);
34
35#[derive(Error, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
36#[error("{0} is not a valid UID root")]
37pub struct UidRootError(pub String);
38
39impl UidRoot {
40    pub fn new(uid_root: &str) -> Result<Self, UidRootError> {
41        let regex = UID_ROOT_REGEX.get_or_init(|| {
42            Regex::new(&format!(
43                r"^([1-9][0-9.]{{0,{}}})?$",
44                UID_ROOT_MAX_LENGTH - 1
45            ))
46            .unwrap()
47        });
48
49        if !regex.is_match(uid_root) {
50            return Err(UidRootError(format!(
51                "UID root must be empty or start with 1-9, contain only numbers and dots, and be no longer than {UID_ROOT_MAX_LENGTH} characters"
52            )));
53        }
54
55        Ok(Self(uid_root.into()))
56    }
57
58    /// Returns a string representation of the [`UidRoot`] suitable for use as a UID prefix.
59    ///
60    /// If the [`UidRoot`] is not empty and does not end with a dot, a dot is appended.
61    /// Whitespace is trimmed from both ends in all cases.
62    ///
63    /// # Returns
64    ///
65    /// A `String` containing the formatted UID prefix
66    pub fn as_prefix(&self) -> String {
67        if !self.0.is_empty() && !self.0.ends_with('.') {
68            format!("{}.", self.0.trim())
69        } else {
70            self.0.trim().into()
71        }
72    }
73}
74
75impl Default for UidRoot {
76    /// Default implementation for [`UidRoot`] that returns a [`UidRoot`] instance
77    /// initialized with an empty string.
78    fn default() -> Self {
79        Self("".into())
80    }
81}
82
83impl FromStr for UidRoot {
84    type Err = UidRootError;
85
86    fn from_str(s: &str) -> Result<Self, Self::Err> {
87        UidRoot::new(s)
88    }
89}
90
91impl AsRef<str> for UidRoot {
92    fn as_ref(&self) -> &str {
93        &self.0
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_uid_root_validation() {
103        // Valid cases
104        assert!(UidRoot::new("").is_ok());
105        assert!(UidRoot::new("1").is_ok());
106        assert!(UidRoot::new("1.2.3").is_ok());
107        assert!(UidRoot::new("123.456.").is_ok());
108        assert!(UidRoot::new(&"1".repeat(32)).is_ok());
109
110        // Invalid cases
111        assert!(UidRoot::new("0123").is_err()); // starts with 0
112        assert!(UidRoot::new("a.1.2").is_err()); // contains letter
113        assert!(UidRoot::new("1.2.3-4").is_err()); // contains invalid character
114        assert!(UidRoot::new(&"1".repeat(33)).is_err()); // too long
115    }
116
117    #[test]
118    fn test_uid_root_from_str() {
119        // Valid cases
120        let uid_root: Result<UidRoot, _> = "1.2.736.120".parse();
121        assert!(uid_root.is_ok());
122
123        let uid_root: Result<UidRoot, _> = "".parse();
124        assert!(uid_root.is_ok());
125
126        // Invalid cases
127        let uid_root: Result<UidRoot, _> = "0.1.2".parse();
128        assert!(uid_root.is_err());
129
130        let uid_root: Result<UidRoot, _> = "invalid".parse();
131        assert!(uid_root.is_err());
132    }
133
134    #[test]
135    fn test_uid_root_as_ref() {
136        // Test empty string
137        let uid_root = UidRoot::new("").unwrap();
138        assert_eq!(uid_root.as_ref(), "");
139
140        // Test normal UID root
141        let uid_root = UidRoot::new("1.2.3").unwrap();
142        assert_eq!(uid_root.as_ref(), "1.2.3");
143
144        // Test UID root with trailing dot
145        let uid_root = UidRoot::new("1.2.3.").unwrap();
146        assert_eq!(uid_root.as_ref(), "1.2.3.");
147
148        // Test using as_ref in a function that expects &str
149        fn takes_str(_s: &str) {}
150        let uid_root = UidRoot::new("1.2.3").unwrap();
151        takes_str(uid_root.as_ref());
152    }
153}