Skip to main content

shadowforge_lib/adapters/
scrubber.rs

1//! Adapter implementing the [`StyloScrubber`] port via domain logic.
2
3use crate::domain::errors::ScrubberError;
4use crate::domain::ports::StyloScrubber;
5use crate::domain::scrubber;
6use crate::domain::types::StyloProfile;
7
8/// Default implementation of stylometric scrubbing.
9///
10/// Delegates to the pure domain functions in [`crate::domain::scrubber`].
11pub struct StyloScrubberImpl;
12
13impl StyloScrubberImpl {
14    /// Create a new scrubber adapter.
15    #[must_use]
16    pub const fn new() -> Self {
17        Self
18    }
19}
20
21impl Default for StyloScrubberImpl {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl StyloScrubber for StyloScrubberImpl {
28    fn scrub(&self, text: &str, profile: &StyloProfile) -> Result<String, ScrubberError> {
29        let result = scrubber::scrub_text(text, profile);
30
31        // Verify the result is valid UTF-8 (should always be true for String,
32        // but the port contract requires us to surface InvalidUtf8)
33        if result.is_empty() && !text.is_empty() {
34            // Something went very wrong — original had content but output is empty
35            return Err(ScrubberError::ProfileNotSatisfied {
36                reason: "scrubbing reduced non-empty input to empty output".into(),
37            });
38        }
39
40        Ok(result)
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    type TestResult = Result<(), Box<dyn std::error::Error>>;
49
50    fn profile() -> StyloProfile {
51        StyloProfile {
52            target_vocab_size: 5000,
53            target_avg_sentence_len: 15.0,
54            normalize_punctuation: true,
55        }
56    }
57
58    #[test]
59    fn scrub_via_adapter() -> TestResult {
60        let scrubber = StyloScrubberImpl::new();
61        let input = "Don\u{2019}t worry\u{2014}it\u{2019}s fine!";
62        let result = scrubber.scrub(input, &profile())?;
63        assert!(result.contains("do not"));
64        assert!(result.contains("it is"));
65        assert!(!result.contains('\u{2014}'));
66        Ok(())
67    }
68
69    #[test]
70    fn scrub_empty_returns_empty() -> TestResult {
71        let scrubber = StyloScrubberImpl::new();
72        let result = scrubber.scrub("", &profile())?;
73        assert!(result.is_empty());
74        Ok(())
75    }
76
77    #[test]
78    fn scrub_idempotent_via_adapter() -> TestResult {
79        let scrubber = StyloScrubberImpl::new();
80        let input = "\u{201C}They\u{2019}re coming,\u{201D} she whispered\u{2026}";
81        let once = scrubber.scrub(input, &profile())?;
82        let twice = scrubber.scrub(&once, &profile())?;
83        assert_eq!(once, twice);
84        Ok(())
85    }
86
87    #[test]
88    fn scrub_preserves_non_latin() -> TestResult {
89        let scrubber = StyloScrubberImpl::new();
90        let input = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} \u{0628}\u{0627}\u{0644}\u{0639}\u{0627}\u{0644}\u{0645}";
91        let result = scrubber.scrub(input, &profile())?;
92        assert!(!result.is_empty());
93        Ok(())
94    }
95
96    #[test]
97    fn default_impl() -> TestResult {
98        let scrubber = StyloScrubberImpl;
99        let result = scrubber.scrub("hello world", &profile())?;
100        assert_eq!(result, "hello world");
101        Ok(())
102    }
103}