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    }
66}
67
68fn push_block_sentences(block: &str, results: &mut Vec<Sentence>) {
69    let chars: Vec<char> = block.chars().collect();
70    let mut buffer = String::new();
71
72    for (idx, ch) in chars.iter().enumerate() {
73        buffer.push(*ch);
74        if is_sentence_boundary(&chars, idx) {
75            let trimmed = buffer.trim();
76            if !trimmed.is_empty() {
77                results.push(trimmed.to_string());
78            }
79            buffer.clear();
80        }
81    }
82
83    let trailing = buffer.trim();
84    if !trailing.is_empty() {
85        results.push(trailing.to_string());
86    }
87}
88
89fn is_sentence_boundary(chars: &[char], idx: usize) -> bool {
90    match chars[idx] {
91        '.' => is_dot_boundary(chars, idx),
92        '!' | '?' => true,
93        _ => false,
94    }
95}
96
97fn is_dot_boundary(chars: &[char], idx: usize) -> bool {
98    if is_decimal_middle(chars, idx) || is_ticker_middle(chars, idx) {
99        return false;
100    }
101    if idx + 1 < chars.len() && chars[idx + 1] == '.' {
102        return false;
103    }
104    true
105}
106
107fn is_decimal_middle(chars: &[char], idx: usize) -> bool {
108    idx > 0
109        && idx + 1 < chars.len()
110        && chars[idx - 1].is_ascii_digit()
111        && chars[idx + 1].is_ascii_digit()
112}
113
114fn is_ticker_middle(chars: &[char], idx: usize) -> bool {
115    idx > 0
116        && idx + 1 < chars.len()
117        && is_ticker_char(chars[idx - 1])
118        && is_ticker_char(chars[idx + 1])
119}
120
121fn is_ticker_char(ch: char) -> bool {
122    ch.is_ascii_uppercase() || ch.is_ascii_digit()
123}
124
125// ---------------------------------------------------------------------------
126// Filesystem helpers
127// ---------------------------------------------------------------------------
128
129/// True if the path has a `.txt` extension (case-insensitive).
130pub fn is_text_file(path: &Path) -> bool {
131    path.extension()
132        .and_then(|ext| ext.to_str())
133        .map(|ext| ext.eq_ignore_ascii_case("txt"))
134        .unwrap_or(false)
135}
136
137/// Best-effort file modified time.
138pub fn file_mtime(path: &Path) -> Option<DateTime<Utc>> {
139    let metadata = fs::metadata(path).ok()?;
140    let modified = metadata.modified().ok()?;
141    Some(system_time_to_utc(modified))
142}
143
144/// Best-effort (created_at, updated_at) pair for a file.
145pub fn file_times(path: &Path) -> (DateTime<Utc>, DateTime<Utc>) {
146    let metadata = fs::metadata(path).ok();
147    let updated_at = metadata
148        .as_ref()
149        .and_then(|meta| meta.modified().ok())
150        .map(system_time_to_utc)
151        .unwrap_or_else(Utc::now);
152    let created_at = metadata
153        .and_then(|meta| meta.created().ok())
154        .map(system_time_to_utc)
155        .unwrap_or(updated_at);
156    (created_at, updated_at)
157}
158
159fn system_time_to_utc(time: std::time::SystemTime) -> DateTime<Utc> {
160    DateTime::<Utc>::from(time)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn normalize_inline_whitespace_collapses_runs() {
169        let nl = platform_newline();
170        let input = format!("Alpha{nl}{nl}  Beta\tGamma");
171        assert_eq!(normalize_inline_whitespace(&input), "Alpha Beta Gamma");
172    }
173
174    #[test]
175    fn sentences_falls_back_to_full_text_when_needed() {
176        let text = format!("   {}", platform_newline());
177        let result = sentences(&text);
178        assert!(result.is_empty());
179
180        let text2 = "Single block without punctuation";
181        let result2 = sentences(text2);
182        assert_eq!(
183            result2,
184            vec![String::from("Single block without punctuation")]
185        );
186    }
187
188    #[test]
189    fn make_section_populates_sentences() {
190        let section = make_section(SectionRole::Context, Some("Summary"), "Line one. Line two!");
191        assert_eq!(section.heading.as_deref(), Some("Summary"));
192        assert_eq!(section.sentences.len(), 2);
193        assert_eq!(section.role, SectionRole::Context);
194    }
195
196    #[test]
197    fn sentences_keep_decimal_values_together() {
198        let text = "Price closed at 3.14. Outlook improved.";
199        let result = sentences(text);
200        assert_eq!(result, vec!["Price closed at 3.14.", "Outlook improved."]);
201    }
202
203    #[test]
204    fn sentences_keep_dot_tickers_together() {
205        let text = "BRK.B rallied while RDS.A lagged.";
206        let result = sentences(text);
207        assert_eq!(result, vec!["BRK.B rallied while RDS.A lagged."]);
208    }
209
210    #[test]
211    fn file_time_helpers_handle_existing_and_missing_paths() {
212        use tempfile::tempdir;
213        let temp = tempdir().unwrap();
214        let existing = temp.path().join("exists.txt");
215        std::fs::write(&existing, "hello").unwrap();
216
217        assert!(file_mtime(&existing).is_some());
218        let (created_at, updated_at) = file_times(&existing);
219        assert!(updated_at >= created_at);
220
221        let missing = temp.path().join("missing.txt");
222        assert!(file_mtime(&missing).is_none());
223        let (missing_created, missing_updated) = file_times(&missing);
224        assert!(missing_updated >= missing_created);
225    }
226
227    #[test]
228    fn sentences_treat_blank_line_as_boundary() {
229        let nl = platform_newline();
230        let text = format!("First line without punctuation{nl}{nl}Second line with more context.");
231        let result = sentences(&text);
232        assert_eq!(
233            result,
234            vec![
235                "First line without punctuation".to_string(),
236                "Second line with more context.".to_string()
237            ]
238        );
239    }
240
241    #[test]
242    fn sentences_keep_ellipsis_together() {
243        let text = "Wait... really? Yes.";
244        let result = sentences(text);
245        assert_eq!(result, vec!["Wait...", "really?", "Yes."]);
246    }
247
248    #[test]
249    fn is_text_file_matches_txt_case_insensitively() {
250        use std::path::PathBuf;
251        assert!(is_text_file(&PathBuf::from("hello.txt")));
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.md")));
255        assert!(!is_text_file(&PathBuf::from("hello")));
256    }
257}