Skip to main content

redact_core/engine/
mod.rs

1// Copyright 2026 Censgate LLC.
2// Licensed under the Apache License, Version 2.0. See the LICENSE file
3// in the project root for license information.
4
5use crate::anonymizers::{AnonymizerConfig, AnonymizerRegistry};
6use crate::recognizers::{
7    generic::GenericSecretRecognizer, pattern::PatternRecognizer, RecognizerRegistry,
8};
9use crate::types::{AnalysisMetadata, AnalysisResult, AnonymizedResult, EntityType};
10use anyhow::Result;
11use std::sync::Arc;
12use std::time::Instant;
13
14/// Monotonic timer for `processing_time_ms`.
15///
16/// `std::time::Instant::now()` is not implemented on `wasm32-unknown-unknown`
17/// (it panics at runtime). On WASM we record no elapsed time; on every other
18/// target we use a real `Instant`, preserving native behavior exactly.
19struct Timer {
20    start: Option<Instant>,
21}
22
23impl Timer {
24    fn new() -> Self {
25        Self {
26            #[cfg(not(target_arch = "wasm32"))]
27            start: Some(Instant::now()),
28            #[cfg(target_arch = "wasm32")]
29            start: None,
30        }
31    }
32
33    fn elapsed_ms(&self) -> u64 {
34        self.start
35            .map(|s| s.elapsed().as_millis() as u64)
36            .unwrap_or(0)
37    }
38}
39
40/// Main analyzer engine that coordinates recognition and anonymization
41#[derive(Debug, Clone)]
42pub struct AnalyzerEngine {
43    recognizer_registry: RecognizerRegistry,
44    anonymizer_registry: AnonymizerRegistry,
45    default_language: String,
46    model_version: Option<String>,
47}
48
49impl AnalyzerEngine {
50    /// Create a new analyzer engine with default recognizers
51    pub fn new() -> Self {
52        let mut recognizer_registry = RecognizerRegistry::new();
53
54        // Add default pattern recognizer
55        let pattern_recognizer = Arc::new(PatternRecognizer::new());
56        recognizer_registry.add_recognizer(pattern_recognizer);
57        recognizer_registry.add_recognizer(Arc::new(GenericSecretRecognizer::new()));
58
59        Self {
60            recognizer_registry,
61            anonymizer_registry: AnonymizerRegistry::new(),
62            default_language: "en".to_string(),
63            model_version: None,
64        }
65    }
66
67    /// Create a builder for custom configuration
68    pub fn builder() -> AnalyzerEngineBuilder {
69        AnalyzerEngineBuilder::new()
70    }
71
72    /// Set the default language
73    pub fn with_language(mut self, language: impl Into<String>) -> Self {
74        self.default_language = language.into();
75        self
76    }
77
78    /// Set the model version (for NER)
79    pub fn with_model_version(mut self, version: impl Into<String>) -> Self {
80        self.model_version = Some(version.into());
81        self
82    }
83
84    /// Get the recognizer registry
85    pub fn recognizer_registry(&self) -> &RecognizerRegistry {
86        &self.recognizer_registry
87    }
88
89    /// Get mutable access to the recognizer registry
90    pub fn recognizer_registry_mut(&mut self) -> &mut RecognizerRegistry {
91        &mut self.recognizer_registry
92    }
93
94    /// Get the anonymizer registry
95    pub fn anonymizer_registry(&self) -> &AnonymizerRegistry {
96        &self.anonymizer_registry
97    }
98
99    /// Get mutable access to the anonymizer registry
100    pub fn anonymizer_registry_mut(&mut self) -> &mut AnonymizerRegistry {
101        &mut self.anonymizer_registry
102    }
103
104    /// Analyze text and detect PII entities
105    pub fn analyze(&self, text: &str, language: Option<&str>) -> Result<AnalysisResult> {
106        let start = Timer::new();
107        let lang = language.unwrap_or(&self.default_language);
108
109        let detected_entities = self.recognizer_registry.analyze(text, lang)?;
110
111        let processing_time_ms = start.elapsed_ms();
112
113        Ok(AnalysisResult {
114            original_text: None,
115            detected_entities,
116            anonymized: None,
117            metadata: AnalysisMetadata {
118                recognizers_used: self.recognizer_registry.recognizers().len(),
119                processing_time_ms,
120                language: lang.to_string(),
121                model_version: self.model_version.clone(),
122            },
123        })
124    }
125
126    /// Analyze text with specific entity types
127    pub fn analyze_with_entities(
128        &self,
129        text: &str,
130        entity_types: &[EntityType],
131        language: Option<&str>,
132    ) -> Result<AnalysisResult> {
133        let start = Timer::new();
134        let lang = language.unwrap_or(&self.default_language);
135
136        let detected_entities =
137            self.recognizer_registry
138                .analyze_with_entities(text, lang, entity_types)?;
139
140        let processing_time_ms = start.elapsed_ms();
141
142        Ok(AnalysisResult {
143            original_text: None,
144            detected_entities,
145            anonymized: None,
146            metadata: AnalysisMetadata {
147                recognizers_used: self.recognizer_registry.recognizers().len(),
148                processing_time_ms,
149                language: lang.to_string(),
150                model_version: self.model_version.clone(),
151            },
152        })
153    }
154
155    /// Anonymize text based on detected entities
156    pub fn anonymize(
157        &self,
158        text: &str,
159        language: Option<&str>,
160        config: &AnonymizerConfig,
161    ) -> Result<AnonymizedResult> {
162        let lang = language.unwrap_or(&self.default_language);
163
164        // First, analyze to detect entities
165        let analysis = self.analyze(text, Some(lang))?;
166
167        // Then anonymize
168        self.anonymizer_registry
169            .anonymize(text, analysis.detected_entities, config)
170    }
171
172    /// Analyze and anonymize in one call
173    pub fn analyze_and_anonymize(
174        &self,
175        text: &str,
176        language: Option<&str>,
177        config: &AnonymizerConfig,
178    ) -> Result<AnalysisResult> {
179        let start = Timer::new();
180        let lang = language.unwrap_or(&self.default_language);
181
182        // Analyze
183        let mut result = self.analyze(text, Some(lang))?;
184
185        // Anonymize
186        let anonymized =
187            self.anonymizer_registry
188                .anonymize(text, result.detected_entities.clone(), config)?;
189
190        result.anonymized = Some(anonymized);
191        result.metadata.processing_time_ms = start.elapsed_ms();
192
193        Ok(result)
194    }
195}
196
197impl Default for AnalyzerEngine {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203/// Builder for AnalyzerEngine
204pub struct AnalyzerEngineBuilder {
205    recognizer_registry: RecognizerRegistry,
206    anonymizer_registry: AnonymizerRegistry,
207    default_language: String,
208    model_version: Option<String>,
209}
210
211impl AnalyzerEngineBuilder {
212    pub fn new() -> Self {
213        Self {
214            recognizer_registry: RecognizerRegistry::new(),
215            anonymizer_registry: AnonymizerRegistry::new(),
216            default_language: "en".to_string(),
217            model_version: None,
218        }
219    }
220
221    pub fn with_recognizer_registry(mut self, registry: RecognizerRegistry) -> Self {
222        self.recognizer_registry = registry;
223        self
224    }
225
226    pub fn with_anonymizer_registry(mut self, registry: AnonymizerRegistry) -> Self {
227        self.anonymizer_registry = registry;
228        self
229    }
230
231    pub fn with_language(mut self, language: impl Into<String>) -> Self {
232        self.default_language = language.into();
233        self
234    }
235
236    pub fn with_model_version(mut self, version: impl Into<String>) -> Self {
237        self.model_version = Some(version.into());
238        self
239    }
240
241    pub fn build(self) -> AnalyzerEngine {
242        AnalyzerEngine {
243            recognizer_registry: self.recognizer_registry,
244            anonymizer_registry: self.anonymizer_registry,
245            default_language: self.default_language,
246            model_version: self.model_version,
247        }
248    }
249}
250
251impl Default for AnalyzerEngineBuilder {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::anonymizers::AnonymizationStrategy;
261
262    #[test]
263    fn test_analyzer_engine_new() {
264        let engine = AnalyzerEngine::new();
265        assert_eq!(engine.default_language, "en");
266        assert!(!engine.recognizer_registry.recognizers().is_empty());
267    }
268
269    #[test]
270    fn test_analyze() {
271        let engine = AnalyzerEngine::new();
272        let text = "Email: john@example.com, Phone: (555) 123-4567";
273
274        let result = engine.analyze(text, None).unwrap();
275
276        assert!(result.detected_entities.len() >= 2);
277        assert_eq!(result.metadata.language, "en");
278        assert!(result.metadata.processing_time_ms > 0);
279    }
280
281    #[test]
282    fn test_analyze_with_entities() {
283        let engine = AnalyzerEngine::new();
284        let text = "Email: john@example.com, Phone: (555) 123-4567";
285
286        let result = engine
287            .analyze_with_entities(text, &[EntityType::EmailAddress], None)
288            .unwrap();
289
290        assert!(result
291            .detected_entities
292            .iter()
293            .all(|e| e.entity_type == EntityType::EmailAddress));
294    }
295
296    #[test]
297    fn test_anonymize() {
298        let engine = AnalyzerEngine::new();
299        let text = "Email: john@example.com";
300        let config = AnonymizerConfig {
301            strategy: AnonymizationStrategy::Replace,
302            ..Default::default()
303        };
304
305        let result = engine.anonymize(text, None, &config).unwrap();
306
307        assert!(result.text.contains("[EMAIL_ADDRESS]"));
308    }
309
310    #[test]
311    fn test_analyze_and_anonymize() {
312        let engine = AnalyzerEngine::new();
313        let text = "Email: john@example.com, SSN: 123-45-6789";
314        let config = AnonymizerConfig {
315            strategy: AnonymizationStrategy::Replace,
316            ..Default::default()
317        };
318
319        let result = engine.analyze_and_anonymize(text, None, &config).unwrap();
320
321        assert!(result.detected_entities.len() >= 2);
322        assert!(result.anonymized.is_some());
323
324        let anonymized = result.anonymized.unwrap();
325        assert!(anonymized.text.contains("[EMAIL_ADDRESS]"));
326        assert!(anonymized.text.contains("[US_SSN]"));
327    }
328
329    #[test]
330    fn test_builder() {
331        let engine = AnalyzerEngine::builder()
332            .with_language("es")
333            .with_model_version("v1.0.0")
334            .build();
335
336        assert_eq!(engine.default_language, "es");
337        assert_eq!(engine.model_version, Some("v1.0.0".to_string()));
338    }
339}