1use 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
11pub fn platform_newline() -> &'static str {
13 LineEnding::from_current_platform().as_str()
14}
15
16pub 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
35pub 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(¶graph_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
55pub 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
126pub 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
138pub 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
145pub 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}