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 }
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
125pub 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
137pub 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
144pub 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}