Skip to main content

triplets_core/
utils.rs

1//! Text normalization helpers shared by source implementations.
2
3use chrono::{DateTime, Utc};
4use line_ending::LineEnding;
5use std::fs;
6use std::path::Path;
7
8use crate::data::{RecordSection, SectionRole};
9use crate::types::Sentence;
10
11/// Returns the newline string for the current platform (`"\n"` on Unix, `"\r\n"` on Windows).
12pub fn platform_newline() -> &'static str {
13    LineEnding::from_current_platform().as_str()
14}
15
16/// Collapse repeated whitespace in-place while preserving single spaces.
17/// Collapse runs of whitespace into single spaces and trim.
18pub fn normalize_inline_whitespace<T: AsRef<str>>(text: T) -> String {
19    let mut normalized = String::new();
20    let mut seen_space = false;
21    for ch in text.as_ref().chars() {
22        if ch.is_whitespace() {
23            if !seen_space {
24                normalized.push(' ');
25                seen_space = true;
26            }
27        } else {
28            normalized.push(ch);
29            seen_space = false;
30        }
31    }
32    normalized.trim().to_string()
33}
34
35/// Split a block of text into sentences, falling back to the whole string when needed.
36/// Heuristic sentence splitter with tokenizer-friendly rules.
37pub fn sentences(text: &str) -> Vec<Sentence> {
38    let mut results = Vec::new();
39    let normalized = LineEnding::normalize(text);
40    let paragraph_sep = LineEnding::LF.as_str().repeat(2);
41    for block in normalized.split(&paragraph_sep) {
42        if block.trim().is_empty() {
43            continue;
44        }
45        let normalized = normalize_inline_whitespace(block);
46        if normalized.is_empty() {
47            continue;
48        }
49        push_block_sentences(&normalized, &mut results);
50    }
51
52    results
53}
54
55/// Convenience helper to construct a `RecordSection` with normalized text metadata.
56/// Convenience helper to build a `RecordSection` with precomputed sentences.
57pub fn make_section(role: SectionRole, heading: Option<&str>, text: &str) -> RecordSection {
58    use crate::tokenizer::{Tokenizer, WhitespaceTokenizer};
59    RecordSection {
60        role,
61        heading: heading.map(|h| h.to_string()),
62        text: text.to_string(),
63        sentences: sentences(text),
64        token_count: WhitespaceTokenizer.token_count(text),
65        embedding: None,
66    }
67}
68
69fn push_block_sentences(block: &str, results: &mut Vec<Sentence>) {
70    let chars: Vec<char> = block.chars().collect();
71    let mut buffer = String::new();
72
73    for (idx, ch) in chars.iter().enumerate() {
74        buffer.push(*ch);
75        if is_sentence_boundary(&chars, idx) {
76            let trimmed = buffer.trim();
77            if !trimmed.is_empty() {
78                results.push(trimmed.to_string());
79            }
80            buffer.clear();
81        }
82    }
83
84    let trailing = buffer.trim();
85    if !trailing.is_empty() {
86        results.push(trailing.to_string());
87    }
88}
89
90fn is_sentence_boundary(chars: &[char], idx: usize) -> bool {
91    match chars[idx] {
92        '.' => is_dot_boundary(chars, idx),
93        '!' | '?' => true,
94        _ => false,
95    }
96}
97
98fn is_dot_boundary(chars: &[char], idx: usize) -> bool {
99    if is_decimal_middle(chars, idx) || is_ticker_middle(chars, idx) {
100        return false;
101    }
102    if idx + 1 < chars.len() && chars[idx + 1] == '.' {
103        return false;
104    }
105    true
106}
107
108fn is_decimal_middle(chars: &[char], idx: usize) -> bool {
109    idx > 0
110        && idx + 1 < chars.len()
111        && chars[idx - 1].is_ascii_digit()
112        && chars[idx + 1].is_ascii_digit()
113}
114
115fn is_ticker_middle(chars: &[char], idx: usize) -> bool {
116    idx > 0
117        && idx + 1 < chars.len()
118        && is_ticker_char(chars[idx - 1])
119        && is_ticker_char(chars[idx + 1])
120}
121
122fn is_ticker_char(ch: char) -> bool {
123    ch.is_ascii_uppercase() || ch.is_ascii_digit()
124}
125
126// ---------------------------------------------------------------------------
127// Filesystem helpers
128// ---------------------------------------------------------------------------
129
130/// True if the path has a `.txt` extension (case-insensitive).
131pub fn is_text_file(path: &Path) -> bool {
132    path.extension()
133        .and_then(|ext| ext.to_str())
134        .map(|ext| ext.eq_ignore_ascii_case("txt"))
135        .unwrap_or(false)
136}
137
138/// Best-effort file modified time.
139pub fn file_mtime(path: &Path) -> Option<DateTime<Utc>> {
140    let metadata = fs::metadata(path).ok()?;
141    let modified = metadata.modified().ok()?;
142    Some(system_time_to_utc(modified))
143}
144
145/// Best-effort (created_at, updated_at) pair for a file.
146pub fn file_times(path: &Path) -> (DateTime<Utc>, DateTime<Utc>) {
147    let metadata = fs::metadata(path).ok();
148    let updated_at = metadata
149        .as_ref()
150        .and_then(|meta| meta.modified().ok())
151        .map(system_time_to_utc)
152        .unwrap_or_else(Utc::now);
153    let created_at = metadata
154        .and_then(|meta| meta.created().ok())
155        .map(system_time_to_utc)
156        .unwrap_or(updated_at);
157    (created_at, updated_at)
158}
159
160fn system_time_to_utc(time: std::time::SystemTime) -> DateTime<Utc> {
161    DateTime::<Utc>::from(time)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn normalize_inline_whitespace_collapses_runs() {
170        let nl = platform_newline();
171        let input = format!("Alpha{nl}{nl}  Beta\tGamma");
172        assert_eq!(normalize_inline_whitespace(&input), "Alpha Beta Gamma");
173    }
174
175    #[test]
176    fn sentences_falls_back_to_full_text_when_needed() {
177        let text = format!("   {}", platform_newline());
178        let result = sentences(&text);
179        assert!(result.is_empty());
180
181        let text2 = "Single block without punctuation";
182        let result2 = sentences(text2);
183        assert_eq!(
184            result2,
185            vec![String::from("Single block without punctuation")]
186        );
187    }
188
189    #[test]
190    fn make_section_populates_sentences() {
191        let section = make_section(SectionRole::Context, Some("Summary"), "Line one. Line two!");
192        assert_eq!(section.heading.as_deref(), Some("Summary"));
193        assert_eq!(section.sentences.len(), 2);
194        assert_eq!(section.role, SectionRole::Context);
195    }
196
197    #[test]
198    fn sentences_keep_decimal_values_together() {
199        let text = "Price closed at 3.14. Outlook improved.";
200        let result = sentences(text);
201        assert_eq!(result, vec!["Price closed at 3.14.", "Outlook improved."]);
202    }
203
204    #[test]
205    fn sentences_keep_dot_tickers_together() {
206        let text = "BRK.B rallied while RDS.A lagged.";
207        let result = sentences(text);
208        assert_eq!(result, vec!["BRK.B rallied while RDS.A lagged."]);
209    }
210
211    #[test]
212    fn file_time_helpers_handle_existing_and_missing_paths() {
213        use tempfile::tempdir;
214        let temp = tempdir().unwrap();
215        let existing = temp.path().join("exists.txt");
216        std::fs::write(&existing, "hello").unwrap();
217
218        assert!(file_mtime(&existing).is_some());
219        let (created_at, updated_at) = file_times(&existing);
220        assert!(updated_at >= created_at);
221
222        let missing = temp.path().join("missing.txt");
223        assert!(file_mtime(&missing).is_none());
224        let (missing_created, missing_updated) = file_times(&missing);
225        assert!(missing_updated >= missing_created);
226    }
227
228    #[test]
229    fn sentences_treat_blank_line_as_boundary() {
230        let nl = platform_newline();
231        let text = format!("First line without punctuation{nl}{nl}Second line with more context.");
232        let result = sentences(&text);
233        assert_eq!(
234            result,
235            vec![
236                "First line without punctuation".to_string(),
237                "Second line with more context.".to_string()
238            ]
239        );
240    }
241
242    #[test]
243    fn sentences_keep_ellipsis_together() {
244        let text = "Wait... really? Yes.";
245        let result = sentences(text);
246        assert_eq!(result, vec!["Wait...", "really?", "Yes."]);
247    }
248
249    #[test]
250    fn is_text_file_matches_txt_case_insensitively() {
251        use std::path::PathBuf;
252        assert!(is_text_file(&PathBuf::from("hello.txt")));
253        assert!(is_text_file(&PathBuf::from("hello.TXT")));
254        assert!(is_text_file(&PathBuf::from("hello.Txt")));
255        assert!(!is_text_file(&PathBuf::from("hello.md")));
256        assert!(!is_text_file(&PathBuf::from("hello")));
257    }
258}