Skip to main content

cloakrs_core/
finding.rs

1//! Detection result types.
2
3use crate::{CloakError, Result};
4use serde::{Deserialize, Serialize};
5use std::cmp::Ordering;
6use std::fmt;
7use std::hash::{Hash, Hasher};
8
9/// A detected PII entity within text.
10///
11/// # Examples
12///
13/// ```
14/// use cloakrs_core::{Confidence, EntityType, PiiEntity, Span};
15///
16/// let entity = PiiEntity {
17///     entity_type: EntityType::Email,
18///     span: Span::new(11, 27),
19///     text: "user@example.com".to_string(),
20///     confidence: Confidence::new(0.95).unwrap(),
21///     recognizer_id: "email_regex_v1".to_string(),
22/// };
23///
24/// assert_eq!(entity.span.len(), 16);
25/// ```
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct PiiEntity {
28    /// The type of PII detected.
29    pub entity_type: EntityType,
30    /// Byte offset range in the source text, using `[start, end)`.
31    pub span: Span,
32    /// The matched value.
33    pub text: String,
34    /// Confidence score from `0.0` to `1.0`.
35    pub confidence: Confidence,
36    /// Identifier of the recognizer that produced this finding.
37    pub recognizer_id: String,
38}
39
40/// Byte offset range in source text, using `[start, end)`.
41///
42/// # Examples
43///
44/// ```
45/// use cloakrs_core::Span;
46///
47/// let span = Span::new(3, 8);
48/// assert_eq!(span.len(), 5);
49/// assert!(!span.is_empty());
50/// ```
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub struct Span {
53    /// Inclusive start byte offset.
54    pub start: usize,
55    /// Exclusive end byte offset.
56    pub end: usize,
57}
58
59impl Span {
60    /// Creates a new span.
61    #[must_use]
62    pub const fn new(start: usize, end: usize) -> Self {
63        Self { start, end }
64    }
65
66    /// Returns the span length in bytes.
67    #[must_use]
68    pub const fn len(self) -> usize {
69        self.end.saturating_sub(self.start)
70    }
71
72    /// Returns `true` when this span contains no bytes.
73    #[must_use]
74    pub const fn is_empty(self) -> bool {
75        self.start >= self.end
76    }
77
78    /// Returns `true` if the two spans overlap.
79    #[must_use]
80    pub const fn overlaps(self, other: Self) -> bool {
81        self.start < other.end && other.start < self.end
82    }
83}
84
85/// Confidence score wrapper guaranteed to contain a value from `0.0` to `1.0`.
86///
87/// # Examples
88///
89/// ```
90/// use cloakrs_core::Confidence;
91///
92/// let confidence = Confidence::new(0.8).unwrap();
93/// assert_eq!(confidence.value(), 0.8);
94/// ```
95#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
96pub struct Confidence(f64);
97
98impl Confidence {
99    /// The lowest possible confidence value.
100    pub const ZERO: Self = Self(0.0);
101
102    /// The highest possible confidence value.
103    pub const ONE: Self = Self(1.0);
104
105    /// Creates a confidence score if the value is finite and within `0.0..=1.0`.
106    pub fn new(value: f64) -> Result<Self> {
107        if value.is_finite() && (0.0..=1.0).contains(&value) {
108            Ok(Self(value))
109        } else {
110            Err(CloakError::InvalidConfidence(value))
111        }
112    }
113
114    /// Returns the wrapped numeric value.
115    #[must_use]
116    pub const fn value(self) -> f64 {
117        self.0
118    }
119}
120
121impl Default for Confidence {
122    fn default() -> Self {
123        Self::ONE
124    }
125}
126
127impl fmt::Display for Confidence {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        write!(f, "{:.3}", self.0)
130    }
131}
132
133impl PartialEq for Confidence {
134    fn eq(&self, other: &Self) -> bool {
135        self.0.to_bits() == other.0.to_bits()
136    }
137}
138
139impl Eq for Confidence {}
140
141impl PartialOrd for Confidence {
142    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
143        Some(self.cmp(other))
144    }
145}
146
147impl Ord for Confidence {
148    fn cmp(&self, other: &Self) -> Ordering {
149        self.0.total_cmp(&other.0)
150    }
151}
152
153impl Hash for Confidence {
154    fn hash<H: Hasher>(&self, state: &mut H) {
155        self.0.to_bits().hash(state);
156    }
157}
158
159/// All supported PII entity types.
160///
161/// # Examples
162///
163/// ```
164/// use cloakrs_core::EntityType;
165///
166/// assert_eq!(EntityType::Email.redaction_tag(), "[EMAIL]");
167/// ```
168#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
169pub enum EntityType {
170    /// Email address.
171    Email,
172    /// Phone number.
173    PhoneNumber,
174    /// Payment card number.
175    CreditCard,
176    /// International Bank Account Number.
177    Iban,
178    /// IP address.
179    IpAddress,
180    /// URL.
181    Url,
182    /// Date of birth.
183    DateOfBirth,
184    /// Generic API key.
185    ApiKey,
186    /// JSON Web Token.
187    Jwt,
188    /// AWS access key.
189    AwsAccessKey,
190    /// Cryptocurrency wallet address.
191    CryptoAddress,
192    /// MAC address.
193    MacAddress,
194    /// Internal hostname or machine name.
195    Hostname,
196    /// User home-directory path.
197    UserPath,
198    /// Person name detected from dictionary and context.
199    PersonName,
200    /// Physical street address.
201    PhysicalAddress,
202    /// Passport number.
203    PassportNumber,
204    /// Driver's license number.
205    DriversLicense,
206    /// United States Social Security Number.
207    Ssn,
208    /// Dutch Burgerservicenummer.
209    Bsn,
210    /// UK National Insurance number.
211    Nino,
212    /// UK NHS number.
213    NhsNumber,
214    /// Indian Aadhaar number.
215    Aadhaar,
216    /// Indian PAN card number.
217    Pan,
218    /// Brazilian CPF.
219    Cpf,
220    /// Brazilian CNPJ.
221    Cnpj,
222    /// German tax identifier.
223    SteuerID,
224    /// French INSEE/NIR number.
225    InseeNir,
226    /// User-defined entity type.
227    Custom(String),
228}
229
230impl EntityType {
231    /// Returns the redaction tag for this entity type.
232    #[must_use]
233    pub fn redaction_tag(&self) -> String {
234        match self {
235            Self::Email => "[EMAIL]".to_string(),
236            Self::PhoneNumber => "[PHONE]".to_string(),
237            Self::CreditCard => "[CREDIT_CARD]".to_string(),
238            Self::Iban => "[IBAN]".to_string(),
239            Self::IpAddress => "[IP_ADDRESS]".to_string(),
240            Self::Url => "[URL]".to_string(),
241            Self::DateOfBirth => "[DOB]".to_string(),
242            Self::ApiKey => "[API_KEY]".to_string(),
243            Self::Jwt => "[JWT]".to_string(),
244            Self::AwsAccessKey => "[AWS_KEY]".to_string(),
245            Self::CryptoAddress => "[CRYPTO_ADDR]".to_string(),
246            Self::MacAddress => "[MAC_ADDR]".to_string(),
247            Self::Hostname => "[HOSTNAME]".to_string(),
248            Self::UserPath => "[USER_PATH]".to_string(),
249            Self::PersonName => "[PERSON]".to_string(),
250            Self::PhysicalAddress => "[ADDRESS]".to_string(),
251            Self::PassportNumber => "[PASSPORT]".to_string(),
252            Self::DriversLicense => "[DRIVERS_LICENSE]".to_string(),
253            Self::Ssn => "[SSN]".to_string(),
254            Self::Bsn => "[BSN]".to_string(),
255            Self::Nino => "[NINO]".to_string(),
256            Self::NhsNumber => "[NHS_NUMBER]".to_string(),
257            Self::Aadhaar => "[AADHAAR]".to_string(),
258            Self::Pan => "[PAN]".to_string(),
259            Self::Cpf => "[CPF]".to_string(),
260            Self::Cnpj => "[CNPJ]".to_string(),
261            Self::SteuerID => "[STEUER_ID]".to_string(),
262            Self::InseeNir => "[INSEE_NIR]".to_string(),
263            Self::Custom(name) => format!("[{}]", upper_snake(name)),
264        }
265    }
266}
267
268fn upper_snake(value: &str) -> String {
269    value
270        .chars()
271        .map(|c| {
272            if c.is_ascii_alphanumeric() {
273                c.to_ascii_uppercase()
274            } else {
275                '_'
276            }
277        })
278        .collect()
279}
280
281/// Locale selector used to choose locale-specific recognizers.
282///
283/// # Examples
284///
285/// ```
286/// use cloakrs_core::Locale;
287///
288/// assert!(Locale::US.matches(Locale::Universal));
289/// ```
290#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
291pub enum Locale {
292    /// Universal recognizers that apply to all locales.
293    Universal,
294    /// United States.
295    US,
296    /// Netherlands.
297    NL,
298    /// United Kingdom.
299    UK,
300    /// Germany.
301    DE,
302    /// France.
303    FR,
304    /// India.
305    IN,
306    /// Brazil.
307    BR,
308    /// European Union meta-locale.
309    EU,
310    /// Custom BCP-47-like locale string.
311    Custom(String),
312}
313
314impl Locale {
315    /// Returns true if `candidate` is universal or equals this locale.
316    #[must_use]
317    pub fn matches(&self, candidate: Self) -> bool {
318        candidate == Self::Universal || self == &candidate
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_confidence_new_valid_value_constructs() {
328        let confidence = Confidence::new(0.75).unwrap();
329        assert_eq!(confidence.value(), 0.75);
330    }
331
332    #[test]
333    fn test_confidence_new_above_one_rejects() {
334        assert!(Confidence::new(1.1).is_err());
335    }
336
337    #[test]
338    fn test_confidence_new_nan_rejects() {
339        assert!(Confidence::new(f64::NAN).is_err());
340    }
341
342    #[test]
343    fn test_confidence_ordering_sorts_low_to_high() {
344        let low = Confidence::new(0.2).unwrap();
345        let high = Confidence::new(0.9).unwrap();
346        assert!(low < high);
347    }
348
349    #[test]
350    fn test_span_len_with_ordered_offsets_returns_difference() {
351        assert_eq!(Span::new(4, 10).len(), 6);
352    }
353
354    #[test]
355    fn test_span_overlaps_when_ranges_intersect() {
356        assert!(Span::new(4, 10).overlaps(Span::new(8, 12)));
357    }
358
359    #[test]
360    fn test_entity_type_redaction_tag_for_custom_uppercases_name() {
361        assert_eq!(
362            EntityType::Custom("customer id".to_string()).redaction_tag(),
363            "[CUSTOMER_ID]"
364        );
365    }
366
367    #[test]
368    fn test_pii_entity_serializes_to_json() {
369        let entity = PiiEntity {
370            entity_type: EntityType::Email,
371            span: Span::new(0, 16),
372            text: "user@example.com".to_string(),
373            confidence: Confidence::new(0.95).unwrap(),
374            recognizer_id: "email_regex_v1".to_string(),
375        };
376
377        let json = serde_json::to_string(&entity).unwrap();
378        assert!(json.contains("user@example.com"));
379    }
380}