Skip to main content

iscc_lib/
utils.rs

1//! Text normalization and hashing utilities for ISCC code generation.
2//!
3//! Provides text cleaning, trimming, collapsing, and BLAKE3 multihash functions
4//! ported from `iscc-core` `code_meta.py` and `utils.py`.
5
6#[cfg(feature = "text-processing")]
7use unicode_general_category::{GeneralCategory, get_general_category};
8#[cfg(feature = "text-processing")]
9use unicode_normalization::UnicodeNormalization;
10
11/// Characters treated as newlines (preserved during control-char removal).
12#[cfg(feature = "text-processing")]
13const NEWLINES: &[char] = &[
14    '\u{000A}', // LINE FEED
15    '\u{000B}', // VERTICAL TAB
16    '\u{000C}', // FORM FEED
17    '\u{000D}', // CARRIAGE RETURN
18    '\u{0085}', // NEXT LINE
19    '\u{2028}', // LINE SEPARATOR
20    '\u{2029}', // PARAGRAPH SEPARATOR
21];
22
23/// Check if a character belongs to a Unicode "C" (control/format/etc) category.
24#[cfg(feature = "text-processing")]
25fn is_c_category(c: char) -> bool {
26    matches!(
27        get_general_category(c),
28        GeneralCategory::Control
29            | GeneralCategory::Format
30            | GeneralCategory::Unassigned
31            | GeneralCategory::PrivateUse
32            | GeneralCategory::Surrogate
33    )
34}
35
36/// Check if a character belongs to Unicode "C", "M", or "P" categories.
37#[cfg(feature = "text-processing")]
38fn is_cmp_category(c: char) -> bool {
39    matches!(
40        get_general_category(c),
41        // C: Control categories
42        GeneralCategory::Control
43            | GeneralCategory::Format
44            | GeneralCategory::Unassigned
45            | GeneralCategory::PrivateUse
46            | GeneralCategory::Surrogate
47            // M: Mark categories
48            | GeneralCategory::NonspacingMark
49            | GeneralCategory::SpacingMark
50            | GeneralCategory::EnclosingMark
51            // P: Punctuation categories
52            | GeneralCategory::ConnectorPunctuation
53            | GeneralCategory::DashPunctuation
54            | GeneralCategory::OpenPunctuation
55            | GeneralCategory::ClosePunctuation
56            | GeneralCategory::InitialPunctuation
57            | GeneralCategory::FinalPunctuation
58            | GeneralCategory::OtherPunctuation
59    )
60}
61
62/// Clean and normalize text for display.
63///
64/// Applies NFKC normalization, removes control characters (except newlines),
65/// normalizes `\r\n` to `\n`, collapses consecutive empty lines to at most one,
66/// and strips leading/trailing whitespace. Mirrors the reference `text_clean`
67/// step for step; Unicode data comes from the tables the dependencies ship.
68#[cfg(feature = "text-processing")]
69pub fn text_clean(text: &str) -> String {
70    // 1. NFKC normalize
71    let text: String = text.nfkc().collect();
72
73    // 2. Remove control chars except newlines, normalizing all newlines to \n
74    let mut cleaned = String::with_capacity(text.len());
75    let mut chars = text.chars().peekable();
76    while let Some(c) = chars.next() {
77        if NEWLINES.contains(&c) {
78            // Handle \r\n as a single newline
79            if c == '\r' && chars.peek() == Some(&'\n') {
80                chars.next();
81            }
82            cleaned.push('\n');
83        } else if is_c_category(c) {
84            // Skip control characters
85        } else {
86            cleaned.push(c);
87        }
88    }
89
90    // 3. Split on \n, collapse consecutive empty/whitespace-only lines
91    let mut result_lines: Vec<&str> = Vec::new();
92    let mut prev_empty = false;
93    for line in cleaned.split('\n') {
94        let is_empty = line.trim().is_empty();
95        if is_empty {
96            if prev_empty {
97                continue;
98            }
99            prev_empty = true;
100        } else {
101            prev_empty = false;
102        }
103        result_lines.push(line);
104    }
105
106    // 4. Join with \n and strip leading/trailing whitespace
107    result_lines.join("\n").trim().to_string()
108}
109
110/// Remove newlines and collapse whitespace to single spaces.
111///
112/// Converts multi-line text into a single normalized line by splitting on
113/// whitespace boundaries and joining with a single space.
114pub fn text_remove_newlines(text: &str) -> String {
115    text.split_whitespace().collect::<Vec<_>>().join(" ")
116}
117
118/// Trim text so its UTF-8 encoded size does not exceed `nbytes`.
119///
120/// Finds the largest valid UTF-8 prefix within `nbytes`, then strips
121/// leading/trailing whitespace from the result. Multi-byte characters
122/// that would be split are dropped entirely.
123pub fn text_trim(text: &str, nbytes: usize) -> String {
124    if text.len() <= nbytes {
125        return text.trim().to_string();
126    }
127    let bytes = &text.as_bytes()[..nbytes];
128    let s = match std::str::from_utf8(bytes) {
129        Ok(s) => s,
130        Err(e) => &text[..e.valid_up_to()],
131    };
132    s.trim().to_string()
133}
134
135/// Normalize and simplify text for similarity hashing.
136///
137/// Applies NFD normalization, lowercasing, removes whitespace and characters in
138/// Unicode categories C (control), M (mark), and P (punctuation), then
139/// recombines with NFKC normalization. Mirrors the reference `text_collapse`
140/// step for step; Unicode data comes from the tables the dependencies ship.
141#[cfg(feature = "text-processing")]
142pub fn text_collapse(text: &str) -> String {
143    // 1. NFD normalize, then lowercase
144    let nfd_lower = text.nfd().collect::<String>().to_lowercase();
145
146    // 2. Filter: keep chars that are NOT whitespace AND NOT in C/M/P categories
147    let filtered: String = nfd_lower
148        .chars()
149        .filter(|&c| !c.is_whitespace() && !is_cmp_category(c))
150        .collect();
151
152    // 3. NFKC normalize the filtered result
153    filtered.nfkc().collect()
154}
155
156/// Compute a BLAKE3 hash with multihash prefix.
157///
158/// Returns a hex-encoded string with the BLAKE3 multicodec prefix (0x1e)
159/// and digest length (0x20 = 32 bytes).
160pub(crate) fn multi_hash_blake3(data: &[u8]) -> String {
161    let digest = blake3::hash(data);
162    let mut result = Vec::with_capacity(34);
163    result.push(0x1e); // BLAKE3 multicodec
164    result.push(0x20); // 32 bytes length
165    result.extend_from_slice(digest.as_bytes());
166    hex::encode(result)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    // ---- text_clean tests ----
174
175    #[cfg(feature = "text-processing")]
176    #[test]
177    fn test_text_clean_nfkc_normalization() {
178        // ℍ (U+210D) should normalize to H under NFKC
179        assert!(text_clean("ℍ").contains('H'));
180    }
181
182    #[cfg(feature = "text-processing")]
183    #[test]
184    fn test_text_clean_removes_control_chars() {
185        assert_eq!(text_clean("hello\tworld"), "helloworld");
186    }
187
188    #[cfg(feature = "text-processing")]
189    #[test]
190    fn test_text_clean_preserves_newlines() {
191        assert_eq!(text_clean("hello\nworld"), "hello\nworld");
192    }
193
194    #[cfg(feature = "text-processing")]
195    #[test]
196    fn test_text_clean_collapses_empty_lines() {
197        assert_eq!(text_clean("a\n\n\nb"), "a\n\nb");
198    }
199
200    #[cfg(feature = "text-processing")]
201    #[test]
202    fn test_text_clean_strips_whitespace() {
203        assert_eq!(text_clean("  hello  "), "hello");
204    }
205
206    #[cfg(feature = "text-processing")]
207    #[test]
208    fn test_text_clean_handles_crlf() {
209        assert_eq!(text_clean("a\r\nb"), "a\nb");
210    }
211
212    #[cfg(feature = "text-processing")]
213    #[test]
214    fn test_text_clean_empty() {
215        assert_eq!(text_clean(""), "");
216    }
217
218    // ---- text_remove_newlines tests ----
219
220    #[test]
221    fn test_text_remove_newlines() {
222        assert_eq!(text_remove_newlines("hello\nworld"), "hello world");
223    }
224
225    #[test]
226    fn test_text_remove_newlines_collapses_spaces() {
227        assert_eq!(text_remove_newlines("a  b   c"), "a b c");
228    }
229
230    // ---- text_trim tests ----
231
232    #[test]
233    fn test_text_trim_no_truncation() {
234        assert_eq!(text_trim("hello", 10), "hello");
235    }
236
237    #[test]
238    fn test_text_trim_exact() {
239        assert_eq!(text_trim("hello", 5), "hello");
240    }
241
242    #[test]
243    fn test_text_trim_truncates() {
244        assert_eq!(text_trim("hello world", 5), "hello");
245    }
246
247    #[test]
248    fn test_text_trim_unicode_boundary() {
249        // "é" is 2 bytes in UTF-8 (C3 A9). Truncating at 1 byte should drop it.
250        assert_eq!(text_trim("é", 1), "");
251    }
252
253    #[test]
254    fn test_text_trim_strips() {
255        assert_eq!(text_trim("hello ", 6), "hello");
256    }
257
258    // ---- text_collapse tests ----
259
260    #[cfg(feature = "text-processing")]
261    #[test]
262    fn test_text_collapse_basic() {
263        assert_eq!(text_collapse("Hello World"), "helloworld");
264    }
265
266    #[cfg(feature = "text-processing")]
267    #[test]
268    fn test_text_collapse_strips_accents() {
269        // NFD decomposes accented chars, then M-category marks are filtered
270        assert_eq!(text_collapse("café"), "cafe");
271    }
272
273    #[cfg(feature = "text-processing")]
274    #[test]
275    fn test_text_collapse_strips_punctuation() {
276        assert_eq!(text_collapse("hello, world!"), "helloworld");
277    }
278
279    #[cfg(feature = "text-processing")]
280    #[test]
281    fn test_text_collapse_empty() {
282        assert_eq!(text_collapse(""), "");
283    }
284
285    // ---- multi_hash_blake3 tests ----
286
287    #[test]
288    fn test_multi_hash_blake3_empty() {
289        assert_eq!(
290            multi_hash_blake3(b""),
291            "1e20af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
292        );
293    }
294
295    #[test]
296    fn test_multi_hash_blake3_hello_world() {
297        assert_eq!(
298            multi_hash_blake3(b"hello world"),
299            "1e20d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24"
300        );
301    }
302}